mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PluginExtensions: Added support for sharing functions (#98888)
* feat: add generic plugin extension functions * updated betterer. * Fixed type issues after sync with main. * Remved extensions from datasource and panel. * Added validation for extension function registry. * Added tests and validation logic for function extensions registry. * removed prop already existing on base. * fixed lint error. --------- Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
parent
fbf96916aa
commit
8a8e47fcea
@ -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"]
|
||||
],
|
||||
|
@ -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,
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
PluginExtensionExposedComponentConfig,
|
||||
PluginExtensionAddedComponentConfig,
|
||||
PluginExtensionAddedLinkConfig,
|
||||
PluginExtensionAddedFunctionConfig,
|
||||
} from './pluginExtensions';
|
||||
|
||||
/**
|
||||
@ -60,6 +61,7 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
private _exposedComponentConfigs: PluginExtensionExposedComponentConfig[] = [];
|
||||
private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = [];
|
||||
private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = [];
|
||||
private _addedFunctionConfigs: PluginExtensionAddedFunctionConfig[] = [];
|
||||
|
||||
// Content under: /a/${plugin-id}/*
|
||||
root?: ComponentType<AppRootProps<T>>;
|
||||
@ -113,6 +115,10 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
return this._addedLinkConfigs;
|
||||
}
|
||||
|
||||
get addedFunctionConfigs() {
|
||||
return this._addedFunctionConfigs;
|
||||
}
|
||||
|
||||
addLink<Context extends object>(linkConfig: PluginExtensionAddedLinkConfig<Context>) {
|
||||
this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
|
||||
|
||||
@ -125,6 +131,12 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
return this;
|
||||
}
|
||||
|
||||
addFunction<Signature>(addedFunctionConfig: PluginExtensionAddedFunctionConfig<Signature>) {
|
||||
this._addedFunctionConfigs.push(addedFunctionConfig);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
exposeComponent<Props = {}>(componentConfig: PluginExtensionExposedComponentConfig<Props>) {
|
||||
this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig);
|
||||
|
||||
|
@ -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[];
|
||||
|
||||
|
@ -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<Props = {}> = PluginExtensionBase & {
|
||||
component: React.ComponentType<Props>;
|
||||
};
|
||||
|
||||
export type PluginExtension = PluginExtensionLink | PluginExtensionComponent;
|
||||
export type PluginExtensionFunction<Signature = () => 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<Props = {}> = PluginExtensionCon
|
||||
*/
|
||||
component: React.ComponentType<Props>;
|
||||
};
|
||||
export type PluginExtensionAddedFunctionConfig<Signature = unknown> = 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 extends object> = (context: Readonly<Context> | undefined) =>
|
||||
| Partial<{
|
||||
|
@ -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';
|
||||
|
@ -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<Signature> = {
|
||||
isLoading: boolean;
|
||||
functions: Array<PluginExtensionFunction<Signature>>;
|
||||
};
|
||||
|
||||
let singleton: GetPluginExtensions | undefined;
|
||||
|
||||
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions';
|
||||
|
||||
export type UsePluginFunctions<T> = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult<T>;
|
||||
|
||||
let singleton: UsePluginFunctions<unknown> | undefined;
|
||||
|
||||
export function setPluginFunctionsHook(hook: UsePluginFunctions<unknown>): 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<T>(options: UsePluginFunctionsOptions): UsePluginFunctionsResult<T> {
|
||||
if (!singleton) {
|
||||
throw new Error('usePluginFunctions(options) can only be used after the Grafana instance has started.');
|
||||
}
|
||||
return singleton(options) as UsePluginFunctionsResult<T>;
|
||||
}
|
@ -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{},
|
||||
},
|
||||
|
@ -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{},
|
||||
},
|
||||
|
@ -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 {
|
||||
|
@ -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{}
|
||||
}
|
||||
|
@ -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{},
|
||||
},
|
||||
|
@ -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{},
|
||||
},
|
||||
|
@ -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();
|
||||
|
@ -24,6 +24,7 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => {
|
||||
addedComponents: [],
|
||||
extensionPoints: [],
|
||||
exposedComponents: [],
|
||||
addedFunctions: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '',
|
||||
|
@ -163,6 +163,7 @@ export function pluginMetaToPluginConfig(pluginMeta: PluginMeta): AppPluginConfi
|
||||
addedComponents: [],
|
||||
extensionPoints: [],
|
||||
exposedComponents: [],
|
||||
addedFunctions: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -55,6 +55,7 @@ describe('getRuleOrigin', () => {
|
||||
addedComponents: [],
|
||||
extensionPoints: [],
|
||||
exposedComponents: [],
|
||||
addedFunctions: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '',
|
||||
|
@ -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 = {
|
||||
|
@ -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(),
|
||||
}}
|
||||
>
|
||||
<plugin.root
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||
|
||||
import { AddedComponentsRegistry } from 'app/features/plugins/extensions/registry/AddedComponentsRegistry';
|
||||
import { AddedFunctionsRegistry } from 'app/features/plugins/extensions/registry/AddedFunctionsRegistry';
|
||||
import { AddedLinksRegistry } from 'app/features/plugins/extensions/registry/AddedLinksRegistry';
|
||||
import { ExposedComponentsRegistry } from 'app/features/plugins/extensions/registry/ExposedComponentsRegistry';
|
||||
|
||||
@ -13,6 +14,7 @@ export interface ExtensionRegistriesContextType {
|
||||
// Using a different context for each registry to avoid unnecessary re-renders
|
||||
export const AddedLinksRegistryContext = createContext<AddedLinksRegistry | undefined>(undefined);
|
||||
export const AddedComponentsRegistryContext = createContext<AddedComponentsRegistry | undefined>(undefined);
|
||||
export const AddedFunctionsRegistryContext = createContext<AddedFunctionsRegistry | undefined>(undefined);
|
||||
export const ExposedComponentsRegistryContext = createContext<ExposedComponentsRegistry | undefined>(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 (
|
||||
<AddedLinksRegistryContext.Provider value={registries.addedLinksRegistry}>
|
||||
<AddedComponentsRegistryContext.Provider value={registries.addedComponentsRegistry}>
|
||||
<ExposedComponentsRegistryContext.Provider value={registries.exposedComponentsRegistry}>
|
||||
{children}
|
||||
</ExposedComponentsRegistryContext.Provider>
|
||||
<AddedFunctionsRegistryContext.Provider value={registries.addedFunctionsRegistry}>
|
||||
<ExposedComponentsRegistryContext.Provider value={registries.exposedComponentsRegistry}>
|
||||
{children}
|
||||
</ExposedComponentsRegistryContext.Provider>
|
||||
</AddedFunctionsRegistryContext.Provider>
|
||||
</AddedComponentsRegistryContext.Provider>
|
||||
</AddedLinksRegistryContext.Provider>
|
||||
);
|
||||
|
@ -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.';
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { isString } from 'lodash';
|
||||
|
||||
import {
|
||||
type PluginExtension,
|
||||
PluginExtensionTypes,
|
||||
type PluginExtension,
|
||||
type PluginExtensionLink,
|
||||
type PluginExtensionComponent,
|
||||
} from '@grafana/data';
|
||||
|
@ -52,6 +52,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
addedFunctions: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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<AddedFunctionsRegistryItem[], PluginExtensionAddedFunctionConfig> {
|
||||
constructor(
|
||||
options: {
|
||||
registrySubject?: ReplaySubject<RegistryType<AddedFunctionsRegistryItem[]>>;
|
||||
initialState?: RegistryType<AddedFunctionsRegistryItem[]>;
|
||||
} = {}
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
mapToRegistry(
|
||||
registry: RegistryType<AddedFunctionsRegistryItem[]>,
|
||||
item: PluginExtensionConfigs<PluginExtensionAddedFunctionConfig>
|
||||
): RegistryType<AddedFunctionsRegistryItem[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
@ -51,6 +51,7 @@ describe('AddedLinksRegistry', () => {
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
addedFunctions: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
|
@ -52,6 +52,7 @@ describe('ExposedComponentsRegistry', () => {
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
addedFunctions: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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 });
|
||||
});
|
||||
|
@ -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<Signature>({
|
||||
limitPerPlugin,
|
||||
extensionPointId,
|
||||
}: UsePluginFunctionsOptions): UsePluginFunctionsResult<Signature> {
|
||||
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<PluginExtensionFunction<Signature>> = [];
|
||||
const extensionsByPlugin: Record<string, number> = {};
|
||||
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]);
|
||||
}
|
@ -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',
|
||||
|
@ -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: [],
|
||||
},
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -82,7 +82,6 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
|
||||
if (!plugin.panel && plugin.angularPanelCtrl) {
|
||||
plugin.panel = getAngularPanelReactWrapper(plugin);
|
||||
}
|
||||
|
||||
return plugin;
|
||||
})
|
||||
.catch((err) => {
|
||||
|
@ -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<Gene
|
||||
dsPlugin.meta = meta;
|
||||
return dsPlugin;
|
||||
}
|
||||
|
||||
if (pluginExports.Datasource) {
|
||||
const dsPlugin = new DataSourcePlugin<
|
||||
DataSourceApi<DataQuery, DataSourceJsonData>,
|
||||
@ -205,6 +209,10 @@ export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
|
||||
pluginId,
|
||||
configs: plugin.addedLinkConfigs || [],
|
||||
});
|
||||
addedFunctionsRegistry.register({
|
||||
pluginId,
|
||||
configs: plugin.addedFunctionConfigs || [],
|
||||
});
|
||||
|
||||
importedAppPlugins[pluginId] = plugin;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user