mirror of https://github.com/grafana/grafana
Plugins: Support for link extensions (#61663)
* added extensions to plugin.json and exposing it via frontend settings. * added extensions to the plugin.json schema. * changing the extensions in frontend settings to a map instead of an array. * wip * feat(pluginregistry): begin wiring up registry * feat(pluginextensions): prevent duplicate links and clean up * added test case for link extensions. * added tests and implemented the getPluginLink function. * wip * feat(pluginextensions): expose plugin extension registry * fix(pluginextensions): appease the typescript gods post rename * renamed file and will throw error if trying to call setExtensionsRegistry if trying to call it twice. * added reafactorings. * fixed failing test. * minor refactorings to make sure we only include extensions if the app is enabled. * fixed some nits. * Update public/app/features/plugins/extensions/registry.test.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Update packages/grafana-runtime/src/services/pluginExtensions/registry.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Update packages/grafana-runtime/src/services/pluginExtensions/registry.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Update public/app/features/plugins/extensions/registry.test.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Moved types for extensions from data to runtime. * added a small example on how you could consume link extensions. * renamed after feedback from levi. * updated the plugindef.cue. * using the generated plugin def. * added tests for apps and extensions. * fixed linting issues. * wip * wip * wip * wip * test(extensions): fix up failing tests * feat(extensions): freeze registry extension arrays, include type in registry items * added restrictions in the pugindef cue schema. * wip * added required fields. * added key to uniquely identify each item. * test(pluginextensions): align tests with implementation * chore(schema): refresh reference.md --------- Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com> Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>pull/63071/head
parent
8a94688114
commit
1cfd3f81fb
@ -0,0 +1,51 @@ |
||||
import { getPluginExtensions, PluginExtensionsMissingError } from './extensions'; |
||||
import { setPluginsExtensionRegistry } from './registry'; |
||||
|
||||
describe('getPluginExtensions', () => { |
||||
describe('when getting a registered extension link', () => { |
||||
const pluginId = 'grafana-basic-app'; |
||||
const linkId = 'declare-incident'; |
||||
|
||||
beforeAll(() => { |
||||
setPluginsExtensionRegistry({ |
||||
[`plugins/${pluginId}/${linkId}`]: [ |
||||
{ |
||||
type: 'link', |
||||
title: 'Declare incident', |
||||
description: 'Declaring an incident in the app', |
||||
href: `/a/${pluginId}/declare-incident`, |
||||
key: 1, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
it('should return a collection of extensions to the plugin', () => { |
||||
const { extensions, error } = getPluginExtensions({ |
||||
target: `plugins/${pluginId}/${linkId}`, |
||||
}); |
||||
|
||||
expect(extensions[0].href).toBe(`/a/${pluginId}/declare-incident`); |
||||
expect(error).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return a description for the requested link', () => { |
||||
const { extensions, error } = getPluginExtensions({ |
||||
target: `plugins/${pluginId}/${linkId}`, |
||||
}); |
||||
|
||||
expect(extensions[0].href).toBe(`/a/${pluginId}/declare-incident`); |
||||
expect(extensions[0].description).toBe('Declaring an incident in the app'); |
||||
expect(error).toBeUndefined(); |
||||
}); |
||||
|
||||
it('should return an empty array when no links can be found', () => { |
||||
const { extensions, error } = getPluginExtensions({ |
||||
target: `an-unknown-app/${linkId}`, |
||||
}); |
||||
|
||||
expect(extensions.length).toBe(0); |
||||
expect(error).toBeInstanceOf(PluginExtensionsMissingError); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,34 @@ |
||||
import { getPluginsExtensionRegistry, PluginsExtension } from './registry'; |
||||
|
||||
export type GetPluginExtensionsOptions = { |
||||
target: string; |
||||
}; |
||||
|
||||
export type PluginExtensionsResult = { |
||||
extensions: PluginsExtension[]; |
||||
error?: Error; |
||||
}; |
||||
|
||||
export class PluginExtensionsMissingError extends Error { |
||||
readonly target: string; |
||||
|
||||
constructor(target: string) { |
||||
super(`Could not find extensions for '${target}'`); |
||||
this.target = target; |
||||
this.name = PluginExtensionsMissingError.name; |
||||
} |
||||
} |
||||
|
||||
export function getPluginExtensions({ target }: GetPluginExtensionsOptions): PluginExtensionsResult { |
||||
const registry = getPluginsExtensionRegistry(); |
||||
const extensions = registry[target]; |
||||
|
||||
if (!Array.isArray(extensions)) { |
||||
return { |
||||
extensions: [], |
||||
error: new PluginExtensionsMissingError(target), |
||||
}; |
||||
} |
||||
|
||||
return { extensions }; |
||||
} |
||||
@ -0,0 +1,27 @@ |
||||
export type PluginsExtensionLink = { |
||||
type: 'link'; |
||||
title: string; |
||||
description: string; |
||||
href: string; |
||||
key: number; |
||||
}; |
||||
|
||||
export type PluginsExtension = PluginsExtensionLink; |
||||
|
||||
export type PluginsExtensionRegistry = Record<string, PluginsExtension[]>; |
||||
|
||||
let registry: PluginsExtensionRegistry | undefined; |
||||
|
||||
export function setPluginsExtensionRegistry(instance: PluginsExtensionRegistry): void { |
||||
if (registry) { |
||||
throw new Error('setPluginsExtensionRegistry function should only be called once, when Grafana is starting.'); |
||||
} |
||||
registry = instance; |
||||
} |
||||
|
||||
export function getPluginsExtensionRegistry(): PluginsExtensionRegistry { |
||||
if (!registry) { |
||||
throw new Error('getPluginsExtensionRegistry can only be used after the Grafana instance has started.'); |
||||
} |
||||
return registry; |
||||
} |
||||
@ -0,0 +1,56 @@ |
||||
{ |
||||
"type": "app", |
||||
"name": "Test App", |
||||
"id": "test-app", |
||||
"info": { |
||||
"description": "Official Grafana Test App & Dashboard bundle", |
||||
"author": { |
||||
"name": "Test Inc.", |
||||
"url": "http://test.com" |
||||
}, |
||||
"keywords": [ |
||||
"test" |
||||
], |
||||
"links": [ |
||||
{ |
||||
"name": "Project site", |
||||
"url": "http://project.com" |
||||
}, |
||||
{ |
||||
"name": "License & Terms", |
||||
"url": "http://license.com" |
||||
} |
||||
], |
||||
"version": "1.0.0", |
||||
"updated": "2015-02-10" |
||||
}, |
||||
"includes": [ |
||||
{ |
||||
"type": "page", |
||||
"name": "Root Page (react)", |
||||
"path": "/a/my-simple-app", |
||||
"role": "Viewer", |
||||
"addToNav": true, |
||||
"defaultNav": true |
||||
} |
||||
], |
||||
"extensions": [ |
||||
{ |
||||
"target": "plugins/grafana-slo-app/slo-breach", |
||||
"type": "link", |
||||
"title": "Declare incident", |
||||
"description": "Declares a new incident", |
||||
"path": "/incidents/declare" |
||||
}, |
||||
{ |
||||
"target": "plugins/grafana-slo-app/slo-breach", |
||||
"type": "link", |
||||
"title": "Declare incident", |
||||
"description": "Declares a new incident (path without backslash)", |
||||
"path": "incidents/declare" |
||||
} |
||||
], |
||||
"dependencies": { |
||||
"grafanaDependency": ">=8.0.0" |
||||
} |
||||
} |
||||
@ -0,0 +1,100 @@ |
||||
import { AppPluginConfig, PluginExtensionTypes, PluginsExtensionLinkConfig } from '@grafana/runtime'; |
||||
|
||||
import { createPluginExtensionsRegistry } from './registry'; |
||||
|
||||
describe('Plugin registry', () => { |
||||
describe('createPluginExtensionsRegistry function', () => { |
||||
const registry = createPluginExtensionsRegistry({ |
||||
'belugacdn-app': createConfig([ |
||||
{ |
||||
target: 'plugins/belugacdn-app/menu', |
||||
title: 'The title', |
||||
type: PluginExtensionTypes.link, |
||||
description: 'Incidents are occurring!', |
||||
path: '/incidents/declare', |
||||
}, |
||||
]), |
||||
'strava-app': createConfig([ |
||||
{ |
||||
target: 'plugins/strava-app/menu', |
||||
title: 'The title', |
||||
type: PluginExtensionTypes.link, |
||||
description: 'Incidents are occurring!', |
||||
path: '/incidents/declare', |
||||
}, |
||||
]), |
||||
'duplicate-links-app': createConfig([ |
||||
{ |
||||
target: 'plugins/duplicate-links-app/menu', |
||||
title: 'The title', |
||||
type: PluginExtensionTypes.link, |
||||
description: 'Incidents are occurring!', |
||||
path: '/incidents/declare', |
||||
}, |
||||
{ |
||||
target: 'plugins/duplicate-links-app/menu', |
||||
title: 'The title', |
||||
type: PluginExtensionTypes.link, |
||||
description: 'Incidents are occurring!', |
||||
path: '/incidents/declare2', |
||||
}, |
||||
]), |
||||
'no-extensions-app': createConfig(undefined), |
||||
}); |
||||
|
||||
it('should configure a registry link', () => { |
||||
const [link] = registry['plugins/belugacdn-app/menu']; |
||||
|
||||
expect(link).toEqual({ |
||||
title: 'The title', |
||||
type: 'link', |
||||
description: 'Incidents are occurring!', |
||||
href: '/a/belugacdn-app/incidents/declare', |
||||
key: 539074708, |
||||
}); |
||||
}); |
||||
|
||||
it('should configure all registry targets', () => { |
||||
const numberOfTargets = Object.keys(registry).length; |
||||
|
||||
expect(numberOfTargets).toBe(3); |
||||
}); |
||||
|
||||
it('should configure registry targets from multiple plugins', () => { |
||||
const [pluginALink] = registry['plugins/belugacdn-app/menu']; |
||||
const [pluginBLink] = registry['plugins/strava-app/menu']; |
||||
|
||||
expect(pluginALink).toEqual({ |
||||
title: 'The title', |
||||
type: 'link', |
||||
description: 'Incidents are occurring!', |
||||
href: '/a/belugacdn-app/incidents/declare', |
||||
key: 539074708, |
||||
}); |
||||
|
||||
expect(pluginBLink).toEqual({ |
||||
title: 'The title', |
||||
type: 'link', |
||||
description: 'Incidents are occurring!', |
||||
href: '/a/strava-app/incidents/declare', |
||||
key: -1637066384, |
||||
}); |
||||
}); |
||||
|
||||
it('should configure multiple links for a single target', () => { |
||||
const links = registry['plugins/duplicate-links-app/menu']; |
||||
|
||||
expect(links.length).toBe(2); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
function createConfig(extensions?: PluginsExtensionLinkConfig[]): AppPluginConfig { |
||||
return { |
||||
id: 'myorg-basic-app', |
||||
preload: false, |
||||
path: '', |
||||
version: '', |
||||
extensions, |
||||
}; |
||||
} |
||||
@ -0,0 +1,54 @@ |
||||
import { |
||||
AppPluginConfig, |
||||
PluginExtensionTypes, |
||||
PluginsExtensionLinkConfig, |
||||
PluginsExtensionRegistry, |
||||
PluginsExtensionLink, |
||||
} from '@grafana/runtime'; |
||||
|
||||
export function createPluginExtensionsRegistry(apps: Record<string, AppPluginConfig> = {}): PluginsExtensionRegistry { |
||||
const registry: PluginsExtensionRegistry = {}; |
||||
|
||||
for (const [pluginId, config] of Object.entries(apps)) { |
||||
const extensions = config.extensions; |
||||
|
||||
if (!Array.isArray(extensions)) { |
||||
continue; |
||||
} |
||||
|
||||
for (const extension of extensions) { |
||||
const target = extension.target; |
||||
const item = createRegistryItem(pluginId, extension); |
||||
|
||||
if (!Array.isArray(registry[target])) { |
||||
registry[target] = [item]; |
||||
continue; |
||||
} |
||||
|
||||
registry[target].push(item); |
||||
continue; |
||||
} |
||||
} |
||||
|
||||
for (const key of Object.keys(registry)) { |
||||
Object.freeze(registry[key]); |
||||
} |
||||
|
||||
return Object.freeze(registry); |
||||
} |
||||
|
||||
function createRegistryItem(pluginId: string, extension: PluginsExtensionLinkConfig): PluginsExtensionLink { |
||||
const href = `/a/${pluginId}${extension.path}`; |
||||
|
||||
return Object.freeze({ |
||||
type: PluginExtensionTypes.link, |
||||
title: extension.title, |
||||
description: extension.description, |
||||
href: href, |
||||
key: hashKey(`${extension.title}${href}`), |
||||
}); |
||||
} |
||||
|
||||
function hashKey(key: string): number { |
||||
return Array.from(key).reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0); |
||||
} |
||||
Loading…
Reference in new issue