Plugins: Extend panel menu with links from plugins (#63089)

* feat(plugins): introduce dashboard panel menu placement for adding menu items

* test: add test for getPanelMenu()

* added an unique identifier for each extension.

* added context to getPluginExtensions.

* wip

* Wip

* wiwip

* Wip

* feat: WWWIIIIPPPP 🧨

* Wip

* Renamed some of the types to align a bit better.

* added limit to how many extensions a plugin can register per placement.

* decreased number of items to 2

* will trim the lenght of titles to max 25 chars.

* wrapping configure function with error handling.

* added error handling for all scenarios.

* moved extension menu items to the bottom of the more sub menu.

* added tests for configuring the title.

* minor refactorings.

* changed so you need to specify the full path in package.json.

* wip

* removed unused type.

* big refactor to make things simpler and to centralize all configure error/validation handling.

* added missing import.

* fixed failing tests.

* fixed tests.

* revert(extensions): remove static extensions config in favour of registering via AppPlugin APIs

* removed the compose that didn't work for some reason.

* added tests just to verify that validation and error handling is tied together in configuration function.

* adding some more values to the context.

* draft validation.

* added missing tests for getPanelMenu.

* added more tests.

* refactor(extensions): move logic for validating extension link config to function

* Fixed ts errors.

* Update packages/grafana-data/src/types/app.ts

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* Update packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* refactor(extensions): rename limiter -> pluginPlacementCount

* refactor(getpanelmenu): remove redundant continue statement

---------

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Jack Westbrook
2023-03-02 15:42:00 +01:00
committed by GitHub
parent 5bd2fac9c8
commit 8c8f584b41
36 changed files with 1382 additions and 713 deletions

View File

@@ -3,6 +3,7 @@ import { ComponentType } from 'react';
import { KeyValue } from './data';
import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
import { extensionLinkConfigIsValid, PluginExtensionLink } from './pluginExtensions';
/**
* @public
@@ -49,7 +50,25 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> extends PluginMeta
// TODO anything specific to apps?
}
/**
* These types are towards the plugin developer when extending Grafana or other
* plugins from the module.ts
*/
export type AppConfigureExtension<T, C = object> = (extension: T, context: C) => Partial<T> | undefined;
export type AppPluginExtensionLink = Pick<PluginExtensionLink, 'description' | 'path' | 'title'>;
export type AppPluginExtensionLinkConfig<C extends object = object> = {
title: string;
description: string;
placement: string;
path: string;
configure?: AppConfigureExtension<AppPluginExtensionLink, C>;
};
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
private linkExtensions: AppPluginExtensionLinkConfig[] = [];
// Content under: /a/${plugin-id}/*
root?: ComponentType<AppRootProps<T>>;
@@ -58,7 +77,7 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
* This function may be called multiple times on the same instance.
* The first time, `this.meta` will be undefined
*/
init(meta: AppPluginMeta) {}
init(meta: AppPluginMeta<T>) {}
/**
* Set the component displayed under:
@@ -89,6 +108,22 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
}
}
}
get extensionLinks(): AppPluginExtensionLinkConfig[] {
return this.linkExtensions;
}
configureExtensionLink<C extends object>(config: AppPluginExtensionLinkConfig<C>) {
const { path, description, title, placement } = config;
if (!extensionLinkConfigIsValid({ path, description, title, placement })) {
console.warn('[Plugins] Disabled extension because configureExtensionLink was called with an invalid object.');
return this;
}
this.linkExtensions.push(config as AppPluginExtensionLinkConfig);
return this;
}
}
/**

View File

@@ -51,3 +51,9 @@ export * from './alerts';
export * from './slider';
export * from './accesscontrol';
export * from './icon';
export {
type PluginExtension,
type PluginExtensionLink,
isPluginExtensionLink,
PluginExtensionTypes,
} from './pluginExtensions';

View File

@@ -0,0 +1,34 @@
/**
* These types are exposed when rendering extension points
*/
export enum PluginExtensionTypes {
link = 'link',
}
export type PluginExtension = {
type: PluginExtensionTypes;
title: string;
description: string;
key: number;
};
export type PluginExtensionLink = PluginExtension & {
type: PluginExtensionTypes.link;
path: string;
};
export function isPluginExtensionLink(extension: PluginExtension): extension is PluginExtensionLink {
return extension.type === PluginExtensionTypes.link && 'path' in extension;
}
export function extensionLinkConfigIsValid(props: {
path?: string;
description?: string;
title?: string;
placement?: string;
}) {
const valuesAreStrings = Object.values(props).every((val) => typeof val === 'string' && val.length);
const placementIsValid = props.placement?.startsWith('grafana/') || props.placement?.startsWith('plugins/');
return valuesAreStrings && placementIsValid;
}

View File

@@ -24,24 +24,11 @@ export interface AzureSettings {
managedIdentityEnabled: boolean;
}
export enum PluginExtensionTypes {
link = 'link',
}
export type PluginsExtensionLinkConfig = {
placement: string;
type: PluginExtensionTypes.link;
title: string;
description: string;
path: string;
};
export type AppPluginConfig = {
id: string;
path: string;
version: string;
preload: boolean;
extensions?: PluginsExtensionLinkConfig[];
};
export class GrafanaBootConfig implements GrafanaConfig {

View File

@@ -8,10 +8,15 @@ export * from './legacyAngularInjector';
export * from './live';
export * from './LocationService';
export * from './appEvents';
export { setPluginsExtensionRegistry } from './pluginExtensions/registry';
export type { PluginsExtensionRegistry, PluginsExtensionLink, PluginsExtension } from './pluginExtensions/registry';
export {
type GetPluginExtensionsOptions,
type PluginExtensionRegistry,
type PluginExtensionRegistryItem,
type RegistryConfigureExtension,
setPluginsExtensionRegistry,
} from './pluginExtensions/registry';
export {
type PluginExtensionsOptions,
type PluginExtensionsResult,
getPluginExtensions,
} from './pluginExtensions/extensions';
export { type PluginExtensionPanelContext } from './pluginExtensions/contexts';

View File

@@ -0,0 +1,22 @@
import { RawTimeRange, TimeZone } from '@grafana/data';
type Dashboard = {
uid: string;
title: string;
tags: Readonly<Array<Readonly<string>>>;
};
type Target = {
pluginId: string;
refId: string;
};
export type PluginExtensionPanelContext = Readonly<{
pluginId: string;
id: number;
title: string;
timeRange: Readonly<RawTimeRange>;
timeZone: TimeZone;
dashboard: Readonly<Dashboard>;
targets: Readonly<Array<Readonly<Target>>>;
}>;

View File

@@ -1,51 +1,88 @@
import { getPluginExtensions, PluginExtensionsMissingError } from './extensions';
import { setPluginsExtensionRegistry } from './registry';
import { isPluginExtensionLink, PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
import { getPluginExtensions } from './extensions';
import { PluginExtensionRegistryItem, setPluginsExtensionRegistry } from './registry';
describe('getPluginExtensions', () => {
describe('when getting a registered extension link', () => {
describe('when getting extensions for placement', () => {
const placement = 'grafana/dashboard/panel/menu';
const pluginId = 'grafana-basic-app';
const linkId = 'declare-incident';
beforeAll(() => {
setPluginsExtensionRegistry({
[`plugins/${pluginId}/${linkId}`]: [
{
type: 'link',
[placement]: [
createRegistryLinkItem({
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: `/a/${pluginId}/declare-incident`,
key: 1,
},
}),
],
'plugins/myorg-basic-app/start': [
createRegistryLinkItem({
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: `/a/${pluginId}/declare-incident`,
key: 2,
}),
],
});
});
it('should return a collection of extensions to the plugin', () => {
const { extensions, error } = getPluginExtensions({
placement: `plugins/${pluginId}/${linkId}`,
});
it('should return extensions with correct path', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
expect(extensions[0].path).toBe(`/a/${pluginId}/declare-incident`);
expect(error).toBeUndefined();
assertLinkExtension(extension);
expect(extension.path).toBe(`/a/${pluginId}/declare-incident`);
expect(extensions.length).toBe(1);
});
it('should return a description for the requested link', () => {
const { extensions, error } = getPluginExtensions({
placement: `plugins/${pluginId}/${linkId}`,
});
it('should return extensions with correct description', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
expect(extensions[0].path).toBe(`/a/${pluginId}/declare-incident`);
expect(extensions[0].description).toBe('Declaring an incident in the app');
expect(error).toBeUndefined();
assertLinkExtension(extension);
expect(extension.description).toBe('Declaring an incident in the app');
expect(extensions.length).toBe(1);
});
it('should return an empty array when no links can be found', () => {
const { extensions, error } = getPluginExtensions({
placement: `an-unknown-app/${linkId}`,
it('should return extensions with correct title', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
assertLinkExtension(extension);
expect(extension.title).toBe('Declare incident');
expect(extensions.length).toBe(1);
});
it('should return an empty array when extensions cannot be found', () => {
const { extensions } = getPluginExtensions({
placement: 'plugins/not-installed-app/news',
});
expect(extensions.length).toBe(0);
expect(error).toBeInstanceOf(PluginExtensionsMissingError);
});
});
});
function createRegistryLinkItem(
link: Omit<PluginExtensionLink, 'type'>
): PluginExtensionRegistryItem<PluginExtensionLink> {
return {
configure: undefined,
extension: {
...link,
type: PluginExtensionTypes.link,
},
};
}
function assertLinkExtension(extension: PluginExtension): asserts extension is PluginExtensionLink {
if (!isPluginExtensionLink(extension)) {
throw new Error(`extension is not a link extension`);
}
}

View File

@@ -1,34 +1,37 @@
import { getPluginsExtensionRegistry, PluginsExtension } from './registry';
import { type PluginExtension } from '@grafana/data';
export type GetPluginExtensionsOptions = {
import { getPluginsExtensionRegistry } from './registry';
export type PluginExtensionsOptions<T extends object> = {
placement: string;
context?: T;
};
export type PluginExtensionsResult = {
extensions: PluginsExtension[];
error?: Error;
extensions: PluginExtension[];
};
export class PluginExtensionsMissingError extends Error {
readonly placement: string;
constructor(placement: string) {
super(`Could not find extensions for '${placement}'`);
this.placement = placement;
this.name = PluginExtensionsMissingError.name;
}
}
export function getPluginExtensions({ placement }: GetPluginExtensionsOptions): PluginExtensionsResult {
export function getPluginExtensions<T extends object = {}>(
options: PluginExtensionsOptions<T>
): PluginExtensionsResult {
const { placement, context } = options;
const registry = getPluginsExtensionRegistry();
const extensions = registry[placement];
const items = registry[placement] ?? [];
if (!Array.isArray(extensions)) {
return {
extensions: [],
error: new PluginExtensionsMissingError(placement),
};
}
const extensions = items.reduce<PluginExtension[]>((result, item) => {
if (!context || !item.configure) {
result.push(item.extension);
return result;
}
return { extensions };
const extension = item.configure(context);
if (extension) {
result.push(extension);
}
return result;
}, []);
return {
extensions: extensions,
};
}

View File

@@ -1,25 +1,26 @@
export type PluginsExtensionLink = {
type: 'link';
title: string;
description: string;
path: string;
key: number;
import { PluginExtension } from '@grafana/data';
export type RegistryConfigureExtension<T extends PluginExtension = PluginExtension, C extends object = object> = (
context: C
) => T | undefined;
export type PluginExtensionRegistryItem<T extends PluginExtension = PluginExtension, C extends object = object> = {
extension: T;
configure?: RegistryConfigureExtension<T, C>;
};
export type PluginsExtension = PluginsExtensionLink;
export type PluginExtensionRegistry = Record<string, PluginExtensionRegistryItem[]>;
export type PluginsExtensionRegistry = Record<string, PluginsExtension[]>;
let registry: PluginExtensionRegistry | undefined;
let registry: PluginsExtensionRegistry | undefined;
export function setPluginsExtensionRegistry(instance: PluginsExtensionRegistry): void {
if (registry) {
export function setPluginsExtensionRegistry(instance: PluginExtensionRegistry): void {
if (registry && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginsExtensionRegistry function should only be called once, when Grafana is starting.');
}
registry = instance;
}
export function getPluginsExtensionRegistry(): PluginsExtensionRegistry {
export function getPluginsExtensionRegistry(): PluginExtensionRegistry {
if (!registry) {
throw new Error('getPluginsExtensionRegistry can only be used after the Grafana instance has started.');
}