i18n: wires up translations for plugins (#102853)
* i18n: consolidate i18n types & runtime services * Chore: updates after PR feedback * Chore: updates after feedback * Chore: updates after feedback * Chore: adds feature toggle * Chore: adds locale to backend * Chore: adds locales to i18n instance * Chore: fix missing path in CODEOWNERS * Chore: fix go lint issues * Chore: fix missing path in CODEOWNERS * Chore: updates after PR feedback * Trigger build * Chore: updates after PR feedback * Chore: use resolved language for lookup * Chore: updates after PR feedback * Update pkg/plugins/plugins.go Co-authored-by: Will Browne <wbrowne@users.noreply.github.com> * Chore: updates after PR feedback * Chore: updates after PR feedback --------- Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
This commit is contained in:
co-authored by
Will Browne
parent
7ea0fab606
commit
18ae5d7f0c
@@ -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 |
|
||||
| `localizationForPlugins` | Enables localization for plugins |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
||||
@@ -1055,4 +1055,8 @@ export interface FeatureToggles {
|
||||
* @default true
|
||||
*/
|
||||
alertingRuleRecoverDeleted?: boolean;
|
||||
/**
|
||||
* Enables localization for plugins
|
||||
*/
|
||||
localizationForPlugins?: boolean;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ export interface PluginMeta<T extends KeyValue = {}> {
|
||||
loadingStrategy?: PluginLoadingStrategy;
|
||||
extensions?: PluginExtensions;
|
||||
moduleHash?: string;
|
||||
|
||||
// Paths to the translations for the plugin
|
||||
translations?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PluginDependencyInfo {
|
||||
|
||||
@@ -154,6 +154,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
Sort: getPanelSort(panel.ID),
|
||||
Angular: panel.Angular,
|
||||
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), panel),
|
||||
Translations: panel.Translations,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +490,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
|
||||
Angular: plugin.Angular,
|
||||
MultiValueFilterOperators: plugin.MultiValueFilterOperators,
|
||||
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
|
||||
Translations: plugin.Translations,
|
||||
}
|
||||
|
||||
if ds.JsonData == nil {
|
||||
@@ -571,8 +573,9 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
|
||||
Signature: ds.Signature,
|
||||
Module: ds.Module,
|
||||
// ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), ds),
|
||||
BaseURL: ds.BaseURL,
|
||||
Angular: ds.Angular,
|
||||
BaseURL: ds.BaseURL,
|
||||
Angular: ds.Angular,
|
||||
Translations: ds.Translations,
|
||||
},
|
||||
}
|
||||
if ds.Name == grafanads.DatasourceName {
|
||||
@@ -597,6 +600,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
|
||||
Extensions: plugin.Extensions,
|
||||
Dependencies: plugin.Dependencies,
|
||||
ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin),
|
||||
Translations: plugin.Translations,
|
||||
}
|
||||
|
||||
if settings.Enabled {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -24,6 +25,10 @@ import (
|
||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/authn/authntest"
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
datafakes "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
|
||||
@@ -34,6 +39,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
|
||||
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
|
||||
"github.com/grafana/grafana/pkg/services/updatechecker"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
@@ -96,6 +102,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
|
||||
SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), nil, &ssosettingstests.MockService{}),
|
||||
managedPluginsService: managedplugins.NewNoop(),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
DataSourcesService: &datafakes.FakeDataSourceService{},
|
||||
}
|
||||
|
||||
m := web.New()
|
||||
@@ -455,6 +462,233 @@ func newAppSettings(id string, enabled bool) map[string]*pluginsettings.DTO {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPServer_GetFrontendSettings_translations(t *testing.T) {
|
||||
type settings struct {
|
||||
Datasources map[string]plugins.DataSourceDTO `json:"datasources"`
|
||||
Panels map[string]*plugins.PanelDTO `json:"panels"`
|
||||
Apps map[string]*plugins.AppDTO `json:"apps"`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
pluginStore func() pluginstore.Store
|
||||
expected settings
|
||||
signedInUser *user.SignedInUser
|
||||
}{
|
||||
{
|
||||
desc: "built in datasource plugin with translations",
|
||||
pluginStore: func() pluginstore.Store {
|
||||
return &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{
|
||||
{
|
||||
Module: fmt.Sprintf("/%s/module.js", "test-app"),
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test-app",
|
||||
Info: plugins.Info{Version: "0.5.0"},
|
||||
Type: plugins.TypeDataSource,
|
||||
BuiltIn: true,
|
||||
},
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
expected: settings{
|
||||
Datasources: map[string]plugins.DataSourceDTO{
|
||||
"": {
|
||||
Type: string(plugins.TypeDataSource),
|
||||
JSONData: make(map[string]any),
|
||||
PluginMeta: &plugins.PluginMetaDTO{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test-app",
|
||||
Info: plugins.Info{Version: "0.5.0"},
|
||||
Type: plugins.TypeDataSource,
|
||||
BuiltIn: true,
|
||||
},
|
||||
Module: "/test-app/module.js",
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Panels: map[string]*plugins.PanelDTO{},
|
||||
Apps: map[string]*plugins.AppDTO{},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "non-builtin datasource plugin with translations",
|
||||
pluginStore: func() pluginstore.Store {
|
||||
return &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{
|
||||
{
|
||||
Module: fmt.Sprintf("/%s/module.js", "test-app"),
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test-app",
|
||||
Info: plugins.Info{Version: "0.5.0"},
|
||||
Type: plugins.TypeDataSource,
|
||||
},
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
signedInUser: &user.SignedInUser{
|
||||
OrgID: 1,
|
||||
},
|
||||
expected: settings{
|
||||
Datasources: map[string]plugins.DataSourceDTO{
|
||||
"test-app": {
|
||||
Type: "test-app",
|
||||
Name: "test-app",
|
||||
JSONData: make(map[string]any),
|
||||
Module: "/test-app/module.js",
|
||||
PluginMeta: &plugins.PluginMetaDTO{
|
||||
Module: "/test-app/module.js",
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test-app",
|
||||
Info: plugins.Info{Version: "0.5.0"},
|
||||
Type: plugins.TypeDataSource,
|
||||
},
|
||||
LoadingStrategy: "script",
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Panels: map[string]*plugins.PanelDTO{},
|
||||
Apps: map[string]*plugins.AppDTO{},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "panel plugin with translations",
|
||||
pluginStore: func() pluginstore.Store {
|
||||
return &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{
|
||||
{
|
||||
Module: fmt.Sprintf("/%s/module.js", "test-app"),
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test-app",
|
||||
Info: plugins.Info{Version: "0.5.0"},
|
||||
Type: plugins.TypePanel,
|
||||
},
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
expected: settings{
|
||||
Datasources: map[string]plugins.DataSourceDTO{},
|
||||
Panels: map[string]*plugins.PanelDTO{
|
||||
"test-app": {
|
||||
ID: "test-app",
|
||||
Info: plugins.Info{Version: "0.5.0"},
|
||||
Sort: 100,
|
||||
Module: "/test-app/module.js",
|
||||
LoadingStrategy: "script",
|
||||
ModuleHash: "",
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
Apps: map[string]*plugins.AppDTO{},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "app plugin with translations",
|
||||
pluginStore: func() pluginstore.Store {
|
||||
return &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{
|
||||
{
|
||||
Module: fmt.Sprintf("/%s/module.js", "test-app"),
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test-app",
|
||||
Info: plugins.Info{Version: "0.5.0"},
|
||||
Type: plugins.TypeApp,
|
||||
},
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
expected: settings{
|
||||
Datasources: map[string]plugins.DataSourceDTO{},
|
||||
Panels: map[string]*plugins.PanelDTO{},
|
||||
Apps: map[string]*plugins.AppDTO{
|
||||
"test-app": {
|
||||
ID: "test-app",
|
||||
LoadingStrategy: "script",
|
||||
ModuleHash: "",
|
||||
Path: "/test-app/module.js",
|
||||
Version: "0.5.0",
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), nil, nil)
|
||||
|
||||
// Create a request with the appropriate context
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
if test.signedInUser != nil {
|
||||
_, err := hs.DataSourcesService.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
Name: "test-app",
|
||||
Type: "test-app",
|
||||
OrgID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
m.UseMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
reqContext := &contextmodel.ReqContext{
|
||||
Context: web.FromContext(ctx),
|
||||
SignedInUser: test.signedInUser,
|
||||
PublicDashboardAccessToken: "test-token",
|
||||
}
|
||||
ctx = context.WithValue(ctx, ctxkey.Key{}, reqContext)
|
||||
*reqContext.Req = *reqContext.Req.WithContext(ctx)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
m.ServeHTTP(recorder, req)
|
||||
var got settings
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.EqualValues(t, test.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginAssets() func() *pluginassets.Service {
|
||||
return newPluginAssetsWithConfig(&config.PluginManagementCfg{})
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type Features struct {
|
||||
SkipHostEnvVarsEnabled bool
|
||||
SriChecksEnabled bool
|
||||
PluginsCDNSyncLoaderEnabled bool
|
||||
LocalizationForPlugins bool
|
||||
}
|
||||
|
||||
// NewPluginManagementCfg returns a new PluginManagementCfg.
|
||||
|
||||
@@ -153,3 +153,22 @@ func getBaseDir(pluginDir string) string {
|
||||
}
|
||||
return baseDir
|
||||
}
|
||||
|
||||
func (s *Service) GetTranslations(n PluginInfo) (map[string]string, error) {
|
||||
pathToTranslations, err := s.RelativeURL(n, "locales")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get locales: %w", err)
|
||||
}
|
||||
|
||||
// loop through all the languages specified in the plugin.json and add them to the list
|
||||
translations := map[string]string{}
|
||||
for _, language := range n.pluginJSON.Languages {
|
||||
file := fmt.Sprintf("%s.json", n.pluginJSON.ID)
|
||||
translations[language], err = url.JoinPath(pathToTranslations, language, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("join path: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
@@ -190,6 +190,21 @@ func TestService(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, oneCDNRelativeURL, u)
|
||||
})
|
||||
|
||||
t.Run("GetTranslations", func(t *testing.T) {
|
||||
pluginInfo := NewPluginInfo(jsonData["one"], plugins.ClassExternal, pluginFS("one"), nil)
|
||||
pluginInfo.pluginJSON.Languages = []string{"en-US", "pt-BR"}
|
||||
translations, err := svc.GetTranslations(pluginInfo)
|
||||
require.NoError(t, err)
|
||||
oneCDNURL, err := url.JoinPath(tc.cdnBaseURL, "one", "1.0.0", "public", "plugins", "one")
|
||||
require.NoError(t, err)
|
||||
enURL, err := url.JoinPath(oneCDNURL, "locales", "en-US", "one.json")
|
||||
require.NoError(t, err)
|
||||
ptBRURL, err := url.JoinPath(oneCDNURL, "locales", "pt-BR", "one.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, map[string]string{"en-US": enURL, "pt-BR": ptBRURL}, translations)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ type Opts struct {
|
||||
// New returns a new Bootstrap stage.
|
||||
func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap {
|
||||
if opts.ConstructFunc == nil {
|
||||
opts.ConstructFunc = DefaultConstructFunc(signature.DefaultCalculator(cfg), assetpath.DefaultService(cfg))
|
||||
opts.ConstructFunc = DefaultConstructFunc(cfg, signature.DefaultCalculator(cfg), assetpath.DefaultService(cfg))
|
||||
}
|
||||
|
||||
if opts.DecorateFuncs == nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
|
||||
)
|
||||
@@ -16,11 +17,12 @@ type pluginFactoryFunc func(p *plugins.FoundBundle, pluginClass plugins.Class, s
|
||||
// service to set the plugin's BaseURL, Module, Logos and Screenshots fields.
|
||||
type DefaultPluginFactory struct {
|
||||
assetPath *assetpath.Service
|
||||
features *config.Features
|
||||
}
|
||||
|
||||
// NewDefaultPluginFactory returns a new DefaultPluginFactory.
|
||||
func NewDefaultPluginFactory(assetPath *assetpath.Service) *DefaultPluginFactory {
|
||||
return &DefaultPluginFactory{assetPath: assetPath}
|
||||
func NewDefaultPluginFactory(features *config.Features, assetPath *assetpath.Service) *DefaultPluginFactory {
|
||||
return &DefaultPluginFactory{assetPath: assetPath, features: features}
|
||||
}
|
||||
|
||||
func (f *DefaultPluginFactory) createPlugin(bundle *plugins.FoundBundle, class plugins.Class,
|
||||
@@ -74,6 +76,13 @@ func (f *DefaultPluginFactory) newPlugin(p plugins.FoundPlugin, class plugins.Cl
|
||||
if err = setImages(plugin, f.assetPath, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if f.features.LocalizationForPlugins {
|
||||
if err := setTranslations(plugin, f.assetPath, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return plugin, nil
|
||||
}
|
||||
|
||||
@@ -99,3 +108,13 @@ func setImages(p *plugins.Plugin, assetPath *assetpath.Service, info assetpath.P
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setTranslations(p *plugins.Plugin, assetPath *assetpath.Service, info assetpath.PluginInfo) error {
|
||||
translations, err := assetPath.GetTranslations(info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set translations: %w", err)
|
||||
}
|
||||
|
||||
p.Translations = translations
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ type DefaultConstructor struct {
|
||||
}
|
||||
|
||||
// DefaultConstructFunc is the default ConstructFunc used for the Construct step of the Bootstrap stage.
|
||||
func DefaultConstructFunc(signatureCalculator plugins.SignatureCalculator, assetPath *assetpath.Service) ConstructFunc {
|
||||
return NewDefaultConstructor(signatureCalculator, assetPath).Construct
|
||||
func DefaultConstructFunc(cfg *config.PluginManagementCfg, signatureCalculator plugins.SignatureCalculator, assetPath *assetpath.Service) ConstructFunc {
|
||||
return NewDefaultConstructor(cfg, signatureCalculator, assetPath).Construct
|
||||
}
|
||||
|
||||
// DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage.
|
||||
@@ -37,9 +37,9 @@ func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc {
|
||||
}
|
||||
|
||||
// NewDefaultConstructor returns a new DefaultConstructor.
|
||||
func NewDefaultConstructor(signatureCalculator plugins.SignatureCalculator, assetPath *assetpath.Service) *DefaultConstructor {
|
||||
func NewDefaultConstructor(cfg *config.PluginManagementCfg, signatureCalculator plugins.SignatureCalculator, assetPath *assetpath.Service) *DefaultConstructor {
|
||||
return &DefaultConstructor{
|
||||
pluginFactoryFunc: NewDefaultPluginFactory(assetPath).createPlugin,
|
||||
pluginFactoryFunc: NewDefaultPluginFactory(&cfg.Features, assetPath).createPlugin,
|
||||
signatureCalculator: signatureCalculator,
|
||||
log: log.New("plugins.construct"),
|
||||
}
|
||||
|
||||
+34
-31
@@ -267,14 +267,15 @@ type Signature struct {
|
||||
|
||||
type PluginMetaDTO struct {
|
||||
JSONData
|
||||
Signature SignatureStatus `json:"signature"`
|
||||
Module string `json:"module"`
|
||||
ModuleHash string `json:"moduleHash,omitempty"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Angular AngularMeta `json:"angular"`
|
||||
MultiValueFilterOperators bool `json:"multiValueFilterOperators"`
|
||||
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
|
||||
Extensions Extensions `json:"extensions"`
|
||||
Signature SignatureStatus `json:"signature"`
|
||||
Module string `json:"module"`
|
||||
ModuleHash string `json:"moduleHash,omitempty"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Angular AngularMeta `json:"angular"`
|
||||
MultiValueFilterOperators bool `json:"multiValueFilterOperators"`
|
||||
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
|
||||
Extensions Extensions `json:"extensions"`
|
||||
Translations map[string]string `json:"translations,omitempty"`
|
||||
}
|
||||
|
||||
type DataSourceDTO struct {
|
||||
@@ -310,32 +311,34 @@ type DataSourceDTO struct {
|
||||
}
|
||||
|
||||
type PanelDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AliasIDs []string `json:"aliasIds,omitempty"`
|
||||
Info Info `json:"info"`
|
||||
HideFromList bool `json:"hideFromList"`
|
||||
Sort int `json:"sort"`
|
||||
SkipDataQuery bool `json:"skipDataQuery"`
|
||||
ReleaseState string `json:"state"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Signature string `json:"signature"`
|
||||
Module string `json:"module"`
|
||||
Angular AngularMeta `json:"angular"`
|
||||
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
|
||||
ModuleHash string `json:"moduleHash,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AliasIDs []string `json:"aliasIds,omitempty"`
|
||||
Info Info `json:"info"`
|
||||
HideFromList bool `json:"hideFromList"`
|
||||
Sort int `json:"sort"`
|
||||
SkipDataQuery bool `json:"skipDataQuery"`
|
||||
ReleaseState string `json:"state"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Signature string `json:"signature"`
|
||||
Module string `json:"module"`
|
||||
Angular AngularMeta `json:"angular"`
|
||||
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
|
||||
ModuleHash string `json:"moduleHash,omitempty"`
|
||||
Translations map[string]string `json:"translations,omitempty"`
|
||||
}
|
||||
|
||||
type AppDTO struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Version string `json:"version"`
|
||||
Preload bool `json:"preload"`
|
||||
Angular AngularMeta `json:"angular"`
|
||||
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
|
||||
Extensions Extensions `json:"extensions"`
|
||||
Dependencies Dependencies `json:"dependencies"`
|
||||
ModuleHash string `json:"moduleHash,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Version string `json:"version"`
|
||||
Preload bool `json:"preload"`
|
||||
Angular AngularMeta `json:"angular"`
|
||||
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
|
||||
Extensions Extensions `json:"extensions"`
|
||||
Dependencies Dependencies `json:"dependencies"`
|
||||
ModuleHash string `json:"moduleHash,omitempty"`
|
||||
Translations map[string]string `json:"translations,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -64,6 +64,8 @@ type Plugin struct {
|
||||
SkipHostEnvVars bool
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
Translations map[string]string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -129,6 +131,9 @@ type JSONData struct {
|
||||
|
||||
// App Service Auth Registration
|
||||
IAM *auth.IAM `json:"iam,omitempty"`
|
||||
|
||||
// List of languages supported by the plugin
|
||||
Languages []string `json:"languages,omitempty"`
|
||||
}
|
||||
|
||||
func ReadPluginJSON(reader io.Reader) (JSONData, error) {
|
||||
|
||||
@@ -403,6 +403,108 @@ func Test_ReadPluginJSON(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "can read languages in a datasource plugin",
|
||||
pluginJSON: func(t *testing.T) io.ReadCloser {
|
||||
pJSON := `{
|
||||
"id": "myorg-languages-datasource",
|
||||
"name": "Languages Datasource",
|
||||
"type": "datasource",
|
||||
"languages": ["en-US", "pt-BR"]
|
||||
}`
|
||||
return io.NopCloser(strings.NewReader(pJSON))
|
||||
},
|
||||
expected: JSONData{
|
||||
ID: "myorg-languages-datasource",
|
||||
Name: "Languages Datasource",
|
||||
Type: TypeDataSource,
|
||||
Languages: []string{"en-US", "pt-BR"},
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
AddedFunctions: []AddedFunction{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "can read languages in a panel plugin",
|
||||
pluginJSON: func(t *testing.T) io.ReadCloser {
|
||||
pJSON := `{
|
||||
"id": "myorg-languages-panel",
|
||||
"name": "Languages Panel",
|
||||
"type": "panel",
|
||||
"languages": ["en-US", "pt-BR"]
|
||||
}`
|
||||
return io.NopCloser(strings.NewReader(pJSON))
|
||||
},
|
||||
expected: JSONData{
|
||||
ID: "myorg-languages-panel",
|
||||
Name: "Languages Panel",
|
||||
Type: TypePanel,
|
||||
Languages: []string{"en-US", "pt-BR"},
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
AddedFunctions: []AddedFunction{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "can read languages in an app plugin",
|
||||
pluginJSON: func(t *testing.T) io.ReadCloser {
|
||||
pJSON := `{
|
||||
"id": "myorg-languages-app",
|
||||
"name": "Languages App",
|
||||
"type": "app",
|
||||
"languages": ["en-US", "pt-BR"]
|
||||
}`
|
||||
return io.NopCloser(strings.NewReader(pJSON))
|
||||
},
|
||||
expected: JSONData{
|
||||
ID: "myorg-languages-app",
|
||||
Name: "Languages App",
|
||||
Type: TypeApp,
|
||||
Languages: []string{"en-US", "pt-BR"},
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
AddedFunctions: []AddedFunction{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1977,7 +1977,7 @@ func TestGetDashboardUIDByID(t *testing.T) {
|
||||
|
||||
func TestUnstructuredToLegacyDashboard(t *testing.T) {
|
||||
k8sCliMock := new(client.MockK8sHandler)
|
||||
k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{"user:useruid": &user.User{ID: 10, UID: "useruid"}}, nil)
|
||||
k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{"user:useruid": {ID: 10, UID: "useruid"}}, nil)
|
||||
dr := &DashboardServiceImpl{
|
||||
k8sclient: k8sCliMock,
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestDashboardVersionService(t *testing.T) {
|
||||
obj, err := utils.MetaAccessor(dash)
|
||||
require.NoError(t, err)
|
||||
obj.SetUpdatedTimestamp(&updatedTimestamp)
|
||||
mockCli.On("GetUsersFromMeta", mock.Anything, []string{"user:1", ""}).Return(map[string]*user.User{"user:1": &user.User{ID: 1}}, nil)
|
||||
mockCli.On("GetUsersFromMeta", mock.Anything, []string{"user:1", ""}).Return(map[string]*user.User{"user:1": {ID: 1}}, nil)
|
||||
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
|
||||
Items: []unstructured.Unstructured{*dash}}, nil).Once()
|
||||
res, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
|
||||
|
||||
@@ -1818,6 +1818,13 @@ var (
|
||||
HideFromDocs: true,
|
||||
Expression: "true", // enabled by default
|
||||
},
|
||||
{
|
||||
Name: "localizationForPlugins",
|
||||
Description: "Enables localization for plugins",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaPluginsPlatformSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -239,3 +239,4 @@ alertingMigrationUI,experimental,@grafana/alerting-squad,false,false,true
|
||||
unifiedStorageHistoryPruner,experimental,@grafana/search-and-storage,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
|
||||
|
||||
|
@@ -966,4 +966,8 @@ const (
|
||||
// FlagAlertingRuleRecoverDeleted
|
||||
// Enables the UI functionality to recover and view deleted alert rules
|
||||
FlagAlertingRuleRecoverDeleted = "alertingRuleRecoverDeleted"
|
||||
|
||||
// FlagLocalizationForPlugins
|
||||
// Enables localization for plugins
|
||||
FlagLocalizationForPlugins = "localizationForPlugins"
|
||||
)
|
||||
|
||||
@@ -2469,6 +2469,18 @@
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "localizationForPlugins",
|
||||
"resourceVersion": "1742561476144",
|
||||
"creationTimestamp": "2025-03-21T12:51:16Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables localization for plugins",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/plugins-platform-backend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "logQLScope",
|
||||
|
||||
@@ -44,7 +44,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pf finder.Finder, pr
|
||||
|
||||
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, a *assetpath.Service) *bootstrap.Bootstrap {
|
||||
return bootstrap.New(cfg, bootstrap.Opts{
|
||||
ConstructFunc: bootstrap.DefaultConstructFunc(sc, a),
|
||||
ConstructFunc: bootstrap.DefaultConstructFunc(cfg, sc, a),
|
||||
DecorateFuncs: bootstrap.DefaultDecorateFuncs(cfg),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro
|
||||
SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars),
|
||||
SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks),
|
||||
PluginsCDNSyncLoaderEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsCDNSyncLoader),
|
||||
LocalizationForPlugins: features.IsEnabledGlobally(featuremgmt.FlagLocalizationForPlugins),
|
||||
},
|
||||
cfg.AngularSupportEnabled,
|
||||
cfg.GrafanaComAPIURL,
|
||||
|
||||
@@ -35,6 +35,8 @@ type Plugin struct {
|
||||
Angular plugins.AngularMeta
|
||||
|
||||
ExternalService *auth.ExternalService
|
||||
|
||||
Translations map[string]string
|
||||
}
|
||||
|
||||
func (p Plugin) SupportsStreaming() bool {
|
||||
@@ -76,6 +78,7 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
|
||||
BaseURL: p.BaseURL,
|
||||
ExternalService: p.ExternalService,
|
||||
Angular: p.Angular,
|
||||
Translations: p.Translations,
|
||||
}
|
||||
|
||||
if p.Parent != nil {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package pluginstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestToGrafanaDTO(t *testing.T) {
|
||||
plugin := &plugins.Plugin{
|
||||
Translations: map[string]string{
|
||||
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
|
||||
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Translations", func(t *testing.T) {
|
||||
dto := ToGrafanaDTO(plugin)
|
||||
require.Equal(t, plugin.Translations, dto.Translations)
|
||||
})
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { Trans as PluginTrans, setTransComponent, setUseTranslateHook, useTransl
|
||||
|
||||
import { getI18next, Trans, useTranslateInternal } from './index';
|
||||
|
||||
const id = 'frontend-test-locales-plugin';
|
||||
const id = 'frontend-test-languages-plugin';
|
||||
const mockedMeta: PluginMeta = {
|
||||
id,
|
||||
name: 'Frontend Test Locales Plugin',
|
||||
name: 'Frontend Test Languages Plugin',
|
||||
type: PluginType.panel,
|
||||
info: {
|
||||
author: { name: 'Test Author' },
|
||||
|
||||
@@ -64,6 +64,7 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
|
||||
loadingStrategy: fallbackLoadingStrategy,
|
||||
pluginId: meta.id,
|
||||
moduleHash: meta.moduleHash,
|
||||
translations: meta.translations,
|
||||
})
|
||||
.then((pluginExports) => {
|
||||
if (pluginExports.plugin) {
|
||||
|
||||
@@ -22,6 +22,11 @@ export const mockAmdModule = `define([], function() {
|
||||
}
|
||||
});`;
|
||||
|
||||
const mockTranslation = (value: string) =>
|
||||
`System.register([],function(e){return{execute:function(){e("default",{"testKey":"${value}"})}}})`;
|
||||
|
||||
const mockTranslationWithNoDefaultExport = `System.register([],function(e){return{execute:function(){e({"testKey":"unknown"})}}})`;
|
||||
|
||||
const server = setupServer(
|
||||
http.get(
|
||||
'/public/plugins/mockAmdModule/module.js',
|
||||
@@ -49,7 +54,35 @@ const server = setupServer(
|
||||
'Content-Type': 'text/javascript',
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
http.get(
|
||||
'/public/plugins/test-panel/locales/en-US/test-panel.json',
|
||||
() =>
|
||||
new HttpResponse(mockTranslation('testValue'), {
|
||||
headers: {
|
||||
'Content-Type': 'text/javascript',
|
||||
},
|
||||
})
|
||||
),
|
||||
http.get(
|
||||
'/public/plugins/test-panel/locales/pt-BR/test-panel.json',
|
||||
() =>
|
||||
new HttpResponse(mockTranslation('valorDeTeste'), {
|
||||
headers: {
|
||||
'Content-Type': 'text/javascript',
|
||||
},
|
||||
})
|
||||
),
|
||||
http.get(
|
||||
'/public/plugins/test-panel/locales/en-US/no-default-export.json',
|
||||
() =>
|
||||
new HttpResponse(mockTranslationWithNoDefaultExport, {
|
||||
headers: {
|
||||
'Content-Type': 'text/javascript',
|
||||
},
|
||||
})
|
||||
),
|
||||
http.get('/public/plugins/test-panel/locales/en-US/unknown.json', () => HttpResponse.error())
|
||||
);
|
||||
|
||||
export { server };
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { i18n } from 'i18next';
|
||||
|
||||
import { getI18next } from 'app/core/internationalization';
|
||||
|
||||
import { server } from './loader/pluginLoader.mock';
|
||||
import { SystemJS } from './loader/systemjs';
|
||||
import { SystemJSWithLoaderHooks } from './loader/types';
|
||||
import { addTranslationsToI18n } from './plugin_loader';
|
||||
|
||||
describe('plugin_loader', () => {
|
||||
describe('addTranslationsToI18n', () => {
|
||||
const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototype;
|
||||
const originalFetch = systemJSPrototype.fetch;
|
||||
const originalResolve = systemJSPrototype.resolve;
|
||||
let addResourceBundleSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
systemJSPrototype.resolve = (moduleId: string) => moduleId;
|
||||
systemJSPrototype.shouldFetch = () => true;
|
||||
// because server.listen() patches fetch, we need to reassign this to the systemJSPrototype
|
||||
// this is identical to what happens in the original code: https://github.com/systemjs/systemjs/blob/main/src/features/fetch-load.js#L12
|
||||
systemJSPrototype.fetch = window.fetch;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
addResourceBundleSpy = jest
|
||||
.spyOn(getI18next(), 'addResourceBundle')
|
||||
.mockImplementation(() => ({}) as unknown as i18n);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
SystemJS.constructor.prototype.resolve = originalResolve;
|
||||
SystemJS.constructor.prototype.fetch = originalFetch;
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should add translations that match the resolved language first', async () => {
|
||||
const translations = {
|
||||
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
|
||||
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
|
||||
};
|
||||
|
||||
await addTranslationsToI18n({
|
||||
resolvedLanguage: 'pt-BR',
|
||||
fallbackLanguage: 'en-US',
|
||||
pluginId: 'test-panel',
|
||||
translations,
|
||||
});
|
||||
|
||||
expect(addResourceBundleSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addResourceBundleSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'pt-BR',
|
||||
'test-panel',
|
||||
{ testKey: 'valorDeTeste' },
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should add translations that match the fallback language if the resolved language is not in the translations', async () => {
|
||||
const translations = {
|
||||
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
|
||||
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
|
||||
};
|
||||
|
||||
await addTranslationsToI18n({
|
||||
resolvedLanguage: 'sv-SE',
|
||||
fallbackLanguage: 'en-US',
|
||||
pluginId: 'test-panel',
|
||||
translations,
|
||||
});
|
||||
|
||||
expect(addResourceBundleSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addResourceBundleSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'en-US',
|
||||
'test-panel',
|
||||
{ testKey: 'testValue' },
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn if no translations are found', async () => {
|
||||
const translations = {
|
||||
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
|
||||
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
|
||||
};
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await addTranslationsToI18n({
|
||||
resolvedLanguage: 'sv-SE',
|
||||
fallbackLanguage: 'sv-SE',
|
||||
pluginId: 'test-panel',
|
||||
translations,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Could not find any translation for plugin test-panel', {
|
||||
resolvedLanguage: 'sv-SE',
|
||||
fallbackLanguage: 'sv-SE',
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn if no exported default is found', async () => {
|
||||
const translations = {
|
||||
'en-US': '/public/plugins/test-panel/locales/en-US/no-default-export.json',
|
||||
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/no-default-export.json',
|
||||
};
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await addTranslationsToI18n({
|
||||
resolvedLanguage: 'en-US',
|
||||
fallbackLanguage: 'en-US',
|
||||
pluginId: 'test-panel',
|
||||
translations,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Could not find default export for plugin test-panel', {
|
||||
resolvedLanguage: 'en-US',
|
||||
fallbackLanguage: 'en-US',
|
||||
path: '/public/plugins/test-panel/locales/en-US/no-default-export.json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn if translations cannot be loaded', async () => {
|
||||
const translations = {
|
||||
'en-US': '/public/plugins/test-panel/locales/en-US/unknown.json',
|
||||
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/unknown.json',
|
||||
};
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await addTranslationsToI18n({
|
||||
resolvedLanguage: 'en-US',
|
||||
fallbackLanguage: 'pt-BR',
|
||||
pluginId: 'test-panel',
|
||||
translations,
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Could not load translation for plugin test-panel', {
|
||||
resolvedLanguage: 'en-US',
|
||||
fallbackLanguage: 'pt-BR',
|
||||
error: new TypeError('Failed to fetch'),
|
||||
path: '/public/plugins/test-panel/locales/en-US/unknown.json',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { getI18next } from 'app/core/internationalization';
|
||||
import { DEFAULT_LANGUAGE } from 'app/core/internationalization/constants';
|
||||
|
||||
import { GenericDataSourcePlugin } from '../datasources/types';
|
||||
|
||||
@@ -82,6 +84,7 @@ type PluginImportInfo = {
|
||||
version?: string;
|
||||
isAngular?: boolean;
|
||||
moduleHash?: string;
|
||||
translations?: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function importPluginModule({
|
||||
@@ -91,11 +94,22 @@ export async function importPluginModule({
|
||||
version,
|
||||
isAngular,
|
||||
moduleHash,
|
||||
translations,
|
||||
}: PluginImportInfo): Promise<System.Module> {
|
||||
if (version) {
|
||||
registerPluginInCache({ path, version, loadingStrategy });
|
||||
}
|
||||
|
||||
// Add locales to i18n for a plugin if the feature toggle is enabled and the plugin has locales
|
||||
if (config.featureToggles.localizationForPlugins && translations) {
|
||||
await addTranslationsToI18n({
|
||||
resolvedLanguage: getI18next().resolvedLanguage ?? DEFAULT_LANGUAGE,
|
||||
fallbackLanguage: DEFAULT_LANGUAGE,
|
||||
pluginId,
|
||||
translations,
|
||||
});
|
||||
}
|
||||
|
||||
const builtIn = builtInPlugins[path];
|
||||
if (builtIn) {
|
||||
// for handling dynamic imports
|
||||
@@ -152,6 +166,7 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<Gene
|
||||
loadingStrategy: fallbackLoadingStrategy,
|
||||
pluginId: meta.id,
|
||||
moduleHash: meta.moduleHash,
|
||||
translations: meta.translations,
|
||||
}).then((pluginExports) => {
|
||||
if (pluginExports.plugin) {
|
||||
const dsPlugin: GenericDataSourcePlugin = pluginExports.plugin;
|
||||
@@ -190,6 +205,7 @@ export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
|
||||
isAngular: meta.angular?.detected ?? meta.angularDetected,
|
||||
loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch,
|
||||
moduleHash: meta.moduleHash,
|
||||
translations: meta.translations,
|
||||
});
|
||||
|
||||
const { plugin = new AppPlugin() } = pluginExports;
|
||||
@@ -218,3 +234,49 @@ export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
interface AddTranslationsToI18nOptions {
|
||||
resolvedLanguage: string;
|
||||
fallbackLanguage: string;
|
||||
pluginId: string;
|
||||
translations: Record<string, string>;
|
||||
}
|
||||
|
||||
// exported for testing purposes only
|
||||
export async function addTranslationsToI18n({
|
||||
resolvedLanguage,
|
||||
fallbackLanguage,
|
||||
pluginId,
|
||||
translations,
|
||||
}: AddTranslationsToI18nOptions): Promise<void> {
|
||||
const resolvedPath = translations[resolvedLanguage];
|
||||
const fallbackPath = translations[fallbackLanguage];
|
||||
const path = resolvedPath ?? fallbackPath;
|
||||
|
||||
if (!path) {
|
||||
console.warn(`Could not find any translation for plugin ${pluginId}`, { resolvedLanguage, fallbackLanguage });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await SystemJS.import(resolveModulePath(path));
|
||||
if (!module.default) {
|
||||
console.warn(`Could not find default export for plugin ${pluginId}`, {
|
||||
resolvedLanguage,
|
||||
fallbackLanguage,
|
||||
path,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const language = resolvedPath ? resolvedLanguage : fallbackLanguage;
|
||||
getI18next().addResourceBundle(language, pluginId, module.default, undefined, true);
|
||||
} catch (error) {
|
||||
console.warn(`Could not load translation for plugin ${pluginId}`, {
|
||||
resolvedLanguage,
|
||||
fallbackLanguage,
|
||||
error,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user