diff --git a/.betterer.results b/.betterer.results index ca4b89c9605..89b413d6023 100644 --- a/.betterer.results +++ b/.betterer.results @@ -491,6 +491,9 @@ exports[`better eslint`] = { "packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -5602,6 +5605,9 @@ exports[`better eslint`] = { [0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "public/app/features/plugins/extensions/usePluginFunctions.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/plugins/extensions/usePluginLinks.tsx:5381": [ [0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] ], diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 76195d7d8c0..6aa49febd08 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -549,6 +549,7 @@ export { type PluginExtensionLink, type PluginExtensionComponent, type PluginExtensionConfig, + type PluginExtensionFunction, type PluginExtensionLinkConfig, type PluginExtensionComponentConfig, type PluginExtensionEventHelpers, @@ -559,6 +560,7 @@ export { type PluginExtensionExposedComponentConfig, type PluginExtensionAddedComponentConfig, type PluginExtensionAddedLinkConfig, + type PluginExtensionAddedFunctionConfig, } from './types/pluginExtensions'; export { type ScopeDashboardBindingSpec, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 3b33452bd03..23e52f913b8 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -9,6 +9,7 @@ import { PluginExtensionExposedComponentConfig, PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig, + PluginExtensionAddedFunctionConfig, } from './pluginExtensions'; /** @@ -60,6 +61,7 @@ export class AppPlugin extends GrafanaPlugin>; @@ -113,6 +115,10 @@ export class AppPlugin extends GrafanaPlugin(linkConfig: PluginExtensionAddedLinkConfig) { this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig); @@ -125,6 +131,12 @@ export class AppPlugin extends GrafanaPlugin(addedFunctionConfig: PluginExtensionAddedFunctionConfig) { + this._addedFunctionConfigs.push(addedFunctionConfig); + + return this; + } + exposeComponent(componentConfig: PluginExtensionExposedComponentConfig) { this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig); diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index ebb3da684f6..8e1be814c96 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -130,6 +130,8 @@ export interface PluginExtensions { // The component extensions that the plugin registers addedComponents: ExtensionInfo[]; + addedFunctions: ExtensionInfo[]; + // The link extensions that the plugin registers addedLinks: ExtensionInfo[]; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index fcbbecf5f41..5b10f80f143 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -14,6 +14,7 @@ import { RawTimeRange, TimeZone } from './time'; export enum PluginExtensionTypes { link = 'link', component = 'component', + function = 'function', } type PluginExtensionBase = { @@ -36,7 +37,12 @@ export type PluginExtensionComponent = PluginExtensionBase & { component: React.ComponentType; }; -export type PluginExtension = PluginExtensionLink | PluginExtensionComponent; +export type PluginExtensionFunction void> = PluginExtensionBase & { + type: PluginExtensionTypes.function; + fn: Signature; +}; + +export type PluginExtension = PluginExtensionLink | PluginExtensionComponent | PluginExtensionFunction; // Objects used for registering extensions (in app plugins) // -------------------------------------------------------- @@ -74,6 +80,17 @@ export type PluginExtensionAddedComponentConfig = PluginExtensionCon */ component: React.ComponentType; }; +export type PluginExtensionAddedFunctionConfig = PluginExtensionConfigBase & { + /** + * The target extension points where the component will be added + */ + targets: string | string[]; + + /** + * The function to be executed + */ + fn: Signature; +}; export type PluginAddedLinksConfigureFunc = (context: Readonly | undefined) => | Partial<{ diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 3e02e0d2863..5e8892c5cc7 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -22,6 +22,8 @@ export { type UsePluginExtensions, type UsePluginExtensionsResult, type UsePluginComponentResult, + type UsePluginFunctionsOptions, + type UsePluginFunctionsResult, } from './pluginExtensions/getPluginExtensions'; export { setPluginExtensionsHook, @@ -33,6 +35,7 @@ export { export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent'; export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents'; export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks'; +export { setPluginFunctionsHook, usePluginFunctions } from './pluginExtensions/usePluginFunctions'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; export { setCurrentUser } from './user'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index be151b05858..2f70132d5fe 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -1,4 +1,9 @@ -import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data'; +import type { + PluginExtension, + PluginExtensionLink, + PluginExtensionComponent, + PluginExtensionFunction, +} from '@grafana/data'; import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; @@ -52,6 +57,16 @@ export type UsePluginLinksResult = { links: PluginExtensionLink[]; }; +export type UsePluginFunctionsOptions = { + extensionPointId: string; + limitPerPlugin?: number; +}; + +export type UsePluginFunctionsResult = { + isLoading: boolean; + functions: Array>; +}; + let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts new file mode 100644 index 00000000000..1eb86b70e14 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts @@ -0,0 +1,20 @@ +import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions'; + +export type UsePluginFunctions = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult; + +let singleton: UsePluginFunctions | undefined; + +export function setPluginFunctionsHook(hook: UsePluginFunctions): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setUsePluginFunctionsHook() function should only be called once, when Grafana is starting.'); + } + singleton = hook; +} + +export function usePluginFunctions(options: UsePluginFunctionsOptions): UsePluginFunctionsResult { + if (!singleton) { + throw new Error('usePluginFunctions(options) can only be used after the Grafana instance has started.'); + } + return singleton(options) as UsePluginFunctionsResult; +} diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go index 9664f824186..18946548c56 100644 --- a/pkg/plugins/manager/loader/finder/local_test.go +++ b/pkg/plugins/manager/loader/finder/local_test.go @@ -57,6 +57,7 @@ func TestFinder_Find(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -96,8 +97,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -127,8 +130,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -200,8 +205,10 @@ func TestFinder_Find(t *testing.T) { {Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Action: "plugins.app:access"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -238,8 +245,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -269,8 +278,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -300,8 +311,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -340,8 +353,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index d39c9c21303..e5d1b260199 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -106,6 +106,7 @@ func TestLoader_Load(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -201,8 +202,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -249,8 +252,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -304,8 +309,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -398,8 +405,10 @@ func TestLoader_Load(t *testing.T) { {Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 6b9b8e05ad2..8440cb37a11 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -63,6 +63,7 @@ type ExtensionsV2 struct { AddedComponents []AddedComponent `json:"addedComponents"` ExposedComponents []ExposedComponent `json:"exposedComponents"` ExtensionPoints []ExtensionPoint `json:"extensionPoints"` + AddedFunctions []AddedFunction `json:"addedFunctions"` } type Extensions ExtensionsV2 @@ -76,6 +77,7 @@ func (e *Extensions) UnmarshalJSON(data []byte) error { e.AddedLinks = extensionsV2.AddedLinks e.ExposedComponents = extensionsV2.ExposedComponents e.ExtensionPoints = extensionsV2.ExtensionPoints + e.AddedFunctions = extensionsV2.AddedFunctions return nil } @@ -123,6 +125,11 @@ type AddedComponent struct { Description string `json:"description"` } +type AddedFunction struct { + Targets []string `json:"targets"` + Title string `json:"title"` +} + type ExposedComponent struct { Id string `json:"id"` Title string `json:"title"` @@ -267,6 +274,7 @@ type PluginMetaDTO struct { Angular AngularMeta `json:"angular"` MultiValueFilterOperators bool `json:"multiValueFilterOperators"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"` + Extensions Extensions `json:"extensions"` } type DataSourceDTO struct { diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 3239927747b..f815d07c6b1 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -167,6 +167,10 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) { plugin.Extensions.AddedComponents = []AddedComponent{} } + if plugin.Extensions.AddedFunctions == nil { + plugin.Extensions.AddedFunctions = []AddedFunction{} + } + if plugin.Extensions.ExposedComponents == nil { plugin.Extensions.ExposedComponents = []ExposedComponent{} } diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index b3922755603..2a6f85dee07 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -56,6 +56,7 @@ func Test_ReadPluginJSON(t *testing.T) { Extensions: Extensions{ AddedLinks: []AddedLink{}, AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -108,8 +109,10 @@ func Test_ReadPluginJSON(t *testing.T) { Name: "Pie Chart (old)", Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -143,8 +146,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeDataSource, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -188,6 +193,9 @@ func Test_ReadPluginJSON(t *testing.T) { "id": "myorg-extensions-app/component-1/v1" } ], + "addedFunctions": [ + {"targets": ["foo/bar"], "title":"some hook"} + ], "extensionPoints": [ { "title": "Extension point 1", @@ -209,6 +217,7 @@ func Test_ReadPluginJSON(t *testing.T) { {Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}}, }, AddedComponents: []AddedComponent{ + {Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}}, }, ExposedComponents: []ExposedComponent{ @@ -217,6 +226,9 @@ func Test_ReadPluginJSON(t *testing.T) { ExtensionPoints: []ExtensionPoint{ {Id: "myorg-extensions-app/extensions-point-1/v1", Title: "Extension point 1", Description: "Extension points 1 description"}, }, + AddedFunctions: []AddedFunction{ + {Targets: []string{"foo/bar"}, Title: "some hook"}, + }, }, Dependencies: Dependencies{ @@ -271,6 +283,7 @@ func Test_ReadPluginJSON(t *testing.T) { AddedComponents: []AddedComponent{ {Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}}, }, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -301,8 +314,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeApp, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -332,8 +347,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeApp, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -371,6 +388,7 @@ func Test_ReadPluginJSON(t *testing.T) { Extensions: Extensions{ AddedLinks: []AddedLink{}, AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index ca6fef68b50..644058909d4 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -105,6 +105,7 @@ func TestLoader_Load(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -200,8 +201,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -248,8 +251,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -309,8 +314,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -423,8 +430,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -504,8 +513,10 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -615,8 +626,10 @@ func TestLoader_Load_CustomSource(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -696,8 +709,10 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -801,8 +816,10 @@ func TestLoader_Load_RBACReady(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -883,8 +900,10 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { ExposedComponents: []string{}, }}, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -964,8 +983,10 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1060,8 +1081,10 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { {Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1272,8 +1295,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1314,8 +1339,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1463,8 +1490,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1512,8 +1541,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/public/app/app.ts b/public/app/app.ts index f4c4fd5d77c..cc3a2e5e5c5 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -40,6 +40,7 @@ import { setChromeHeaderHeightHook, setPluginLinksHook, setCorrelationsService, + setPluginFunctionsHook, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; @@ -89,6 +90,7 @@ import { pluginExtensionRegistries } from './features/plugins/extensions/registr import { usePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { usePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions'; +import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions'; import { usePluginLinks } from './features/plugins/extensions/usePluginLinks'; import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; @@ -229,6 +231,7 @@ export class GrafanaApp { setPluginLinksHook(usePluginLinks); setPluginComponentHook(usePluginComponent); setPluginComponentsHook(usePluginComponents); + setPluginFunctionsHook(usePluginFunctions); // initialize chrome service const queryParams = locationService.getSearchObject(); diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts index 1d30b05ad50..e0a0142993f 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts @@ -24,6 +24,7 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => { addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '', diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts index dd3fb82ee76..72a94187ef0 100644 --- a/public/app/features/alerting/unified/testSetup/plugins.ts +++ b/public/app/features/alerting/unified/testSetup/plugins.ts @@ -163,6 +163,7 @@ export function pluginMetaToPluginConfig(pluginMeta: PluginMeta): AppPluginConfi addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, }; } diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts index 4b4036c40d2..63c0cb62f04 100644 --- a/public/app/features/alerting/unified/utils/rules.test.ts +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -55,6 +55,7 @@ describe('getRuleOrigin', () => { addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '', diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index 84736aed8de..c9e5ec616be 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -12,6 +12,7 @@ import { Echo } from 'app/core/services/echo/Echo'; import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext'; import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from '../extensions/registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry'; import { getPluginSettings } from '../pluginSettings'; @@ -93,6 +94,7 @@ function renderUnderRouter(page = '') { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; const pagePath = page ? `/${page}` : ''; const route = { diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index f0d6bd5329e..95a280027df 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -29,6 +29,7 @@ import { useAddedLinksRegistry, useAddedComponentsRegistry, useExposedComponentsRegistry, + useAddedFunctionsRegistry, } from '../extensions/ExtensionRegistriesContext'; import { getPluginSettings } from '../pluginSettings'; import { importAppPlugin } from '../plugin_loader'; @@ -60,6 +61,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { const addedLinksRegistry = useAddedLinksRegistry(); const addedComponentsRegistry = useAddedComponentsRegistry(); const exposedComponentsRegistry = useExposedComponentsRegistry(); + const addedFunctionsRegistry = useAddedFunctionsRegistry(); const location = useLocation(); const [state, dispatch] = useReducer(stateSlice.reducer, initialState); const currentUrl = config.appSubUrl + location.pathname + location.search; @@ -104,6 +106,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { addedLinksRegistry: addedLinksRegistry.readOnly(), addedComponentsRegistry: addedComponentsRegistry.readOnly(), exposedComponentsRegistry: exposedComponentsRegistry.readOnly(), + addedFunctionsRegistry: addedFunctionsRegistry.readOnly(), }} > (undefined); export const AddedComponentsRegistryContext = createContext(undefined); +export const AddedFunctionsRegistryContext = createContext(undefined); export const ExposedComponentsRegistryContext = createContext(undefined); export function useAddedLinksRegistry(): AddedLinksRegistry { @@ -31,6 +33,14 @@ export function useAddedComponentsRegistry(): AddedComponentsRegistry { return context; } +export function useAddedFunctionsRegistry(): AddedFunctionsRegistry { + const context = useContext(AddedFunctionsRegistryContext); + if (!context) { + throw new Error('No `AddedFunctionsRegistry` found.'); + } + return context; +} + export function useExposedComponentsRegistry(): ExposedComponentsRegistry { const context = useContext(ExposedComponentsRegistryContext); if (!context) { @@ -46,9 +56,11 @@ export const ExtensionRegistriesProvider = ({ return ( - - {children} - + + + {children} + + ); diff --git a/public/app/features/plugins/extensions/errors.ts b/public/app/features/plugins/extensions/errors.ts index 39c65df0f8e..cb9d22e7b46 100644 --- a/public/app/features/plugins/extensions/errors.ts +++ b/public/app/features/plugins/extensions/errors.ts @@ -8,6 +8,8 @@ export const TITLE_MISSING = 'Title is missing.'; export const DESCRIPTION_MISSING = 'Description is missing.'; +export const INVALID_EXTENSION_FUNCTION = 'The "fn" argument is invalid, it should be a function.'; + export const INVALID_CONFIGURE_FUNCTION = 'The "configure" function is invalid. It should be a function.'; export const INVALID_PATH_OR_ON_CLICK = 'Either "path" or "onClick" is required.'; @@ -33,6 +35,9 @@ export const TITLE_NOT_MATCHING_META_INFO = 'The "title" doesn\'t match the titl export const ADDED_LINK_META_INFO_MISSING = 'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]". Currently, this is only required in development but will be enforced also in production builds in the future.'; +export const ADDED_FUNCTION_META_INFO_MISSING = + 'The extension was not recorded in the plugin.json. Added function extensions must be listed in the section "extensions.addedFunction[]". Currently, this is only required in development but will be enforced also in production builds in the future.'; + export const DESCRIPTION_NOT_MATCHING_META_INFO = 'The "description" doesn\'t match the description recorded in plugin.json.'; diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index a47292c8497..30f5865b259 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -1,8 +1,8 @@ import { isString } from 'lodash'; import { - type PluginExtension, PluginExtensionTypes, + type PluginExtension, type PluginExtensionLink, type PluginExtensionComponent, } from '@grafana/data'; diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index e82c48dcee0..5a18e222431 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -52,6 +52,7 @@ describe('AddedComponentsRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts new file mode 100644 index 00000000000..ae0addc52c7 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts @@ -0,0 +1,677 @@ +import { firstValueFrom } from 'rxjs'; + +import { PluginLoadingStrategy } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { log } from '../logs/log'; +import { resetLogMock } from '../logs/testUtils'; +import { isGrafanaDevMode } from '../utils'; + +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; +import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry'; + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + + // Manually set the dev mode to false + // (to make sure that by default we are testing a production scneario) + isGrafanaDevMode: jest.fn().mockReturnValue(false), +})); + +jest.mock('../logs/log', () => { + const { createLogMock } = jest.requireActual('../logs/testUtils'); + const original = jest.requireActual('../logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + +describe('addedFunctionsRegistry', () => { + const originalApps = config.apps; + const pluginId = 'grafana-basic-app'; + const appPluginConfig = { + id: pluginId, + path: '', + version: '', + preload: false, + angular: { + detected: false, + hideDeprecation: false, + }, + loadingStrategy: PluginLoadingStrategy.fetch, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + extensions: { + addedFunctions: [], + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + + beforeEach(() => { + resetLogMock(log); + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; + }); + + it('should return empty registry when no extensions registered', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + const observable = addedFunctionsRegistry.asObservable(); + const registry = await firstValueFrom(observable); + expect(registry).toEqual({}); + }); + + it('should be possible to register function extensions in the registry', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + + addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn(), + }, + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn(), + }, + ], + }); + + const registry = await addedFunctionsRegistry.getState(); + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: pluginId2, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for a different placement (different plugin)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId2, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for the same placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: pluginId, + + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for a different placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should notify subscribers when the registry changes', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + observable.subscribe(subscribeCallback); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: 'another-plugin', + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(3); + + const registry = subscribeCallback.mock.calls[2][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: 'another-plugin', + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should give the last version of the registry for new subscribers', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should not register a function extension if it has an invalid fn function', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + //@ts-ignore + fn: '...', + }, + ], + }); + + expect(log.error).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not register a function extension if it has invalid properties (empty title)', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: '', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(log.error).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not be possible to register a function on a read-only registry', async () => { + const pluginId = 'grafana-basic-app'; + const registry = new AddedFunctionsRegistry(); + const readOnlyRegistry = registry.readOnly(); + + expect(() => { + readOnlyRegistry.register({ + pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + }).toThrow(MSG_CANNOT_REGISTER_READ_ONLY); + + const currentState = await readOnlyRegistry.getState(); + expect(Object.keys(currentState)).toHaveLength(0); + }); + + it('should pass down fresh registrations to the read-only version of the registry', async () => { + const pluginId = 'grafana-basic-app'; + const registry = new AddedFunctionsRegistry(); + const readOnlyRegistry = registry.readOnly(); + const subscribeCallback = jest.fn(); + let readOnlyState; + + // Should have no extensions registered in the beginning + readOnlyState = await readOnlyRegistry.getState(); + expect(Object.keys(readOnlyState)).toHaveLength(0); + + readOnlyRegistry.asObservable().subscribe(subscribeCallback); + + // Register an extension to the original (writable) registry + registry.register({ + pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // The read-only registry should have received the new extension + readOnlyState = await readOnlyRegistry.getState(); + expect(Object.keys(readOnlyState)).toHaveLength(1); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']); + }); + + it('should not register a function added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = []; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(0); + expect(log.error).toHaveBeenCalled(); + }); + + it('should register a function added by core Grafana in dev-mode even if the meta-info is missing', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + registry.register({ + pluginId: 'grafana', + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should register a function added by a plugin in production mode even if the meta-info is missing', async () => { + // Production mode + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = []; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should register a function added by a plugin in dev-mode if the meta-info is present', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: ['grafana/dashboard/panel/menu'], + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = [fnConfig]; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts new file mode 100644 index 00000000000..d23fe5e78b2 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts @@ -0,0 +1,87 @@ +import { isFunction } from 'lodash'; +import { ReplaySubject } from 'rxjs'; + +import { PluginExtensionAddedFunctionConfig } from '@grafana/data'; + +import * as errors from '../errors'; +import { isGrafanaDevMode } from '../utils'; +import { isAddedFunctionMetaInfoMissing } from '../validators'; + +import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; + +const logPrefix = 'Could not register function extension. Reason:'; + +export type AddedFunctionsRegistryItem = { + pluginId: string; + title: string; + fn: unknown; + description?: string; +}; + +export class AddedFunctionsRegistry extends Registry { + constructor( + options: { + registrySubject?: ReplaySubject>; + initialState?: RegistryType; + } = {} + ) { + super(options); + } + + mapToRegistry( + registry: RegistryType, + item: PluginExtensionConfigs + ): RegistryType { + const { pluginId, configs } = item; + for (const config of configs) { + const configLog = this.logger.child({ + title: config.title, + pluginId, + }); + + if (!config.title) { + configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`); + continue; + } + + if (!isFunction(config.fn)) { + configLog.error(`${logPrefix} ${errors.INVALID_EXTENSION_FUNCTION}`); + continue; + } + + if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) { + continue; + } + + const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets]; + for (const extensionPointId of extensionPointIds) { + const pointIdLog = configLog.child({ extensionPointId }); + + const result = { + pluginId, + fn: config.fn, + description: config.description, + title: config.title, + extensionPointId, + }; + + pointIdLog.debug('Added function extension successfully registered'); + + if (!(extensionPointId in registry)) { + registry[extensionPointId] = [result]; + } else { + registry[extensionPointId].push(result); + } + } + } + + return registry; + } + + // Returns a read-only version of the registry. + readOnly() { + return new AddedFunctionsRegistry({ + registrySubject: this.registrySubject, + }); + } +} diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts index 4d5ab5c084f..d3586240276 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts @@ -51,6 +51,7 @@ describe('AddedLinksRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts index 0a7036894d1..863c89f7b40 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts @@ -52,6 +52,7 @@ describe('ExposedComponentsRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts index 6c2fd1e6a5f..91b7badc4eb 100644 --- a/public/app/features/plugins/extensions/registry/setup.ts +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -1,6 +1,7 @@ import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations'; import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; import { AddedLinksRegistry } from './AddedLinksRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './types'; @@ -8,10 +9,12 @@ import { PluginExtensionRegistries } from './types'; export const addedComponentsRegistry = new AddedComponentsRegistry(); export const exposedComponentsRegistry = new ExposedComponentsRegistry(); export const addedLinksRegistry = new AddedLinksRegistry(); +export const addedFunctionsRegistry = new AddedFunctionsRegistry(); export const pluginExtensionRegistries: PluginExtensionRegistries = { addedComponentsRegistry, exposedComponentsRegistry, addedLinksRegistry, + addedFunctionsRegistry, }; // Registering core extensions diff --git a/public/app/features/plugins/extensions/registry/types.ts b/public/app/features/plugins/extensions/registry/types.ts index 115e859b7d9..1927bf31b75 100644 --- a/public/app/features/plugins/extensions/registry/types.ts +++ b/public/app/features/plugins/extensions/registry/types.ts @@ -1,9 +1,11 @@ import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; import { AddedLinksRegistry } from './AddedLinksRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; export type PluginExtensionRegistries = { addedComponentsRegistry: AddedComponentsRegistry; exposedComponentsRegistry: ExposedComponentsRegistry; + addedFunctionsRegistry: AddedFunctionsRegistry; addedLinksRegistry: AddedLinksRegistry; }; diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 6385c022027..b2a3d3d3435 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -7,6 +7,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -78,6 +79,7 @@ describe('usePluginComponent()', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], // This is necessary, so we can register exposed components to the registry during the tests // (Otherwise the registry would reject it in the imitated production mode) exposedComponents: [exposedComponentConfig], @@ -90,6 +92,7 @@ describe('usePluginComponent()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); jest.mocked(isGrafanaDevMode).mockReturnValue(false); @@ -122,6 +125,7 @@ describe('usePluginComponent()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index 75adda33f75..fcb729fdd64 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -60,6 +61,7 @@ describe('usePluginComponents()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(wrapWithPluginContext).mockClear(); @@ -89,6 +91,7 @@ describe('usePluginComponents()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 739906dd815..479e5cd4c6c 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -19,6 +20,7 @@ describe('usePluginExtensions()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); }); diff --git a/public/app/features/plugins/extensions/usePluginFunctions.tsx b/public/app/features/plugins/extensions/usePluginFunctions.tsx new file mode 100644 index 00000000000..68acee76221 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginFunctions.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data'; +import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime'; + +import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext'; +import * as errors from './errors'; +import { log } from './logs/log'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; +import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; +import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; + +// Returns an array of component extensions for the given extension point +export function usePluginFunctions({ + limitPerPlugin, + extensionPointId, +}: UsePluginFunctionsOptions): UsePluginFunctionsResult { + const registry = useAddedFunctionsRegistry(); + const registryState = useObservable(registry.asObservable()); + const pluginContext = usePluginContext(); + const deps = getExtensionPointPluginDependencies(extensionPointId); + const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps); + + return useMemo(() => { + // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. + const enableRestrictions = isGrafanaDevMode() && pluginContext; + const results: Array> = []; + const extensionsByPlugin: Record = {}; + const pluginId = pluginContext?.meta.id ?? ''; + const pointLog = log.child({ + pluginId, + extensionPointId, + }); + if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { + pointLog.error(errors.INVALID_EXTENSION_POINT_ID); + } + + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { + pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); + return { + isLoading: false, + functions: [], + }; + } + + if (isLoadingAppPlugins) { + return { + isLoading: true, + functions: [], + }; + } + + for (const registryItem of registryState?.[extensionPointId] ?? []) { + const { pluginId } = registryItem; + + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { + continue; + } + + if (extensionsByPlugin[pluginId] === undefined) { + extensionsByPlugin[pluginId] = 0; + } + + results.push({ + id: generateExtensionId(pluginId, extensionPointId, registryItem.title), + type: PluginExtensionTypes.function, + title: registryItem.title, + description: registryItem.description ?? '', + pluginId: pluginId, + fn: registryItem.fn as Signature, + }); + extensionsByPlugin[pluginId] += 1; + } + + return { + isLoading: false, + functions: results, + }; + }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); +} diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index 9186ca0ddf6..f9fb623f42a 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -57,6 +58,7 @@ describe('usePluginLinks()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; resetLogMock(log); @@ -85,6 +87,7 @@ describe('usePluginLinks()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index 32ad6215875..0e2d98f44b3 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -475,6 +475,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, @@ -553,6 +554,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, @@ -584,6 +586,7 @@ describe('Plugin Extensions / Utils', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-third-app': { @@ -623,6 +626,7 @@ describe('Plugin Extensions / Utils', () => { ], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }, }; @@ -679,6 +683,7 @@ describe('Plugin Extensions / Utils', () => { ], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -705,6 +710,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -726,6 +732,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-sixth-app': { @@ -763,6 +770,7 @@ describe('Plugin Extensions / Utils', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; @@ -791,6 +799,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-third-app': { @@ -825,6 +834,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -850,6 +860,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -871,6 +882,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, }; @@ -902,6 +914,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index 12b146bbf5c..f083ff738ea 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -271,6 +271,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const extensionConfig = { @@ -387,6 +388,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const extensionConfig = { @@ -503,6 +505,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const exposedComponentConfig = { @@ -688,6 +691,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index b1dbd5e9af8..cbdaf81e935 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -5,6 +5,7 @@ import type { PluginContextType, PluginExtensionAddedComponentConfig, PluginExtensionExposedComponentConfig, + PluginExtensionAddedFunctionConfig, } from '@grafana/data'; import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions'; import { config, isPluginExtensionLink } from '@grafana/runtime'; @@ -160,6 +161,38 @@ export const isAddedLinkMetaInfoMissing = ( return false; }; +export const isAddedFunctionMetaInfoMissing = ( + pluginId: string, + metaInfo: PluginExtensionAddedFunctionConfig, + log: ExtensionsLog +) => { + const logPrefix = 'Could not register function extension. Reason:'; + const app = config.apps[pluginId]; + const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.find(({ title }) => title === metaInfo.title) : null; + + if (!app) { + log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); + return true; + } + + if (!pluginJsonMetaInfo) { + log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`); + return true; + } + + const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; + if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) { + log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`); + return true; + } + + if (pluginJsonMetaInfo.description !== metaInfo.description) { + log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); + } + + return false; +}; + export const isAddedComponentMetaInfoMissing = ( pluginId: string, metaInfo: PluginExtensionAddedComponentConfig, diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index 45732de7fb1..f6d9c1ac2a6 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -82,7 +82,6 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise { if (!plugin.panel && plugin.angularPanelCtrl) { plugin.panel = getAngularPanelReactWrapper(plugin); } - return plugin; }) .catch((err) => { diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index aa20ae5881f..98915c40f6d 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -13,7 +13,12 @@ import { DataQuery } from '@grafana/schema'; import { GenericDataSourcePlugin } from '../datasources/types'; import builtInPlugins from './built_in_plugins'; -import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup'; +import { + addedComponentsRegistry, + addedFunctionsRegistry, + addedLinksRegistry, + exposedComponentsRegistry, +} from './extensions/registry/setup'; import { getPluginFromCache, registerPluginInCache } from './loader/cache'; // SystemJS has to be imported before the sharedDependenciesMap import { SystemJS } from './loader/systemjs'; @@ -153,7 +158,6 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise, @@ -205,6 +209,10 @@ export async function importAppPlugin(meta: PluginMeta): Promise { pluginId, configs: plugin.addedLinkConfigs || [], }); + addedFunctionsRegistry.register({ + pluginId, + configs: plugin.addedFunctionConfigs || [], + }); importedAppPlugins[pluginId] = plugin;