mirror of https://github.com/grafana/grafana
Plugin Extensions: Clean up the deprecated APIs (#102102)
* PanelMenuBehaviour: stop using the deprecated `getPluginLinkExtensions()` API * Wip * grafana-runtime: remove deprecated APIs `usePluginExtensions()`, `usePluginLinkExtensions()` and `usePluginComponentExtensions()` * Wip * Wip * wip * wip * Chore: removed PluginExtensionLinkConfig * Chore: removed PluginExtensionComponentConfig * Chore: fixed grafana-pyroscope-datasource QueryEditor test * Chore: fixed PublicDashboardScenePage.test.tsx * Chore: fix PanelDataQueriesTab test * Chore: fix PanelMenuBehavior test * Chore: fix transformSceneToSaveModel test * Chore: fix last type errors * Chore: fix alerting/unified/testSetup/plugins.ts * Chore: break out types to separate file * feat(Extensions): expose an observable API for added links and components * chore: prettier fixes * Revert "chore: prettier fixes" This reverts commitpull/102527/head^253aa767664
. * Revert "feat(Extensions): expose an observable API for added links and components" This reverts commitbdc588250e
. --------- Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
parent
c1cadc7d6f
commit
39dcff23f9
@ -1,62 +0,0 @@ |
||||
import { |
||||
PluginPage, |
||||
getPluginComponentExtensions, |
||||
getPluginExtensions, |
||||
getPluginLinkExtensions, |
||||
} from '@grafana/runtime'; |
||||
import { Stack } from '@grafana/ui'; |
||||
|
||||
import { ActionButton } from '../components/ActionButton'; |
||||
import { testIds } from '../testIds'; |
||||
|
||||
type AppExtensionContext = {}; |
||||
type ReusableComponentProps = { |
||||
name: string; |
||||
}; |
||||
|
||||
export function LegacyGetters() { |
||||
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions'; |
||||
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1'; |
||||
const context: AppExtensionContext = {}; |
||||
|
||||
const { extensions } = getPluginExtensions({ |
||||
extensionPointId: extensionPointId1, |
||||
context, |
||||
}); |
||||
|
||||
const { extensions: linkExtensions } = getPluginLinkExtensions({ |
||||
extensionPointId: extensionPointId1, |
||||
}); |
||||
|
||||
const { extensions: componentExtensions } = getPluginComponentExtensions<ReusableComponentProps>({ |
||||
extensionPointId: extensionPointId2, |
||||
}); |
||||
|
||||
return ( |
||||
<PluginPage> |
||||
<Stack direction={'column'} gap={4} data-testid={testIds.legacyGettersPage.container}> |
||||
<section data-testid={testIds.legacyGettersPage.section1}> |
||||
<h3> |
||||
Link extensions defined with configureExtensionLink or configureExtensionComponent and retrived using |
||||
getPluginExtensions |
||||
</h3> |
||||
<ActionButton extensions={extensions} /> |
||||
</section> |
||||
<section data-testid={testIds.legacyGettersPage.section2}> |
||||
<h3>Link extensions defined with configureExtensionLink and retrived using getPluginLinkExtensions</h3> |
||||
<ActionButton extensions={linkExtensions} /> |
||||
</section> |
||||
<section data-testid={testIds.legacyGettersPage.section3}> |
||||
<h3> |
||||
Component extensions defined with configureExtensionComponent and retrived using |
||||
getPluginComponentExtensions |
||||
</h3> |
||||
{componentExtensions.map((extension) => { |
||||
const Component = extension.component; |
||||
return <Component key={extension.id} name="World" />; |
||||
})} |
||||
</section> |
||||
</Stack> |
||||
</PluginPage> |
||||
); |
||||
} |
@ -1,62 +0,0 @@ |
||||
import { |
||||
PluginPage, |
||||
usePluginComponentExtensions, |
||||
usePluginExtensions, |
||||
usePluginLinkExtensions, |
||||
} from '@grafana/runtime'; |
||||
import { Stack } from '@grafana/ui'; |
||||
|
||||
import { ActionButton } from '../components/ActionButton'; |
||||
import { testIds } from '../testIds'; |
||||
|
||||
type AppExtensionContext = {}; |
||||
type ReusableComponentProps = { |
||||
name: string; |
||||
}; |
||||
|
||||
export function LegacyHooks() { |
||||
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions'; |
||||
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1'; |
||||
const context: AppExtensionContext = {}; |
||||
|
||||
const { extensions } = usePluginExtensions({ |
||||
extensionPointId: extensionPointId1, |
||||
context, |
||||
}); |
||||
|
||||
const { extensions: linkExtensions } = usePluginLinkExtensions({ |
||||
extensionPointId: extensionPointId1, |
||||
}); |
||||
|
||||
const { extensions: componentExtensions } = usePluginComponentExtensions<ReusableComponentProps>({ |
||||
extensionPointId: extensionPointId2, |
||||
}); |
||||
|
||||
return ( |
||||
<PluginPage> |
||||
<Stack direction={'column'} gap={4} data-testid={testIds.legacyHooksPage.container}> |
||||
<section data-testid={testIds.legacyHooksPage.section1}> |
||||
<h3> |
||||
Link extensions defined with configureExtensionLink or configureExtensionComponent and retrived using |
||||
usePluginExtensions |
||||
</h3> |
||||
<ActionButton extensions={extensions} /> |
||||
</section> |
||||
<section data-testid={testIds.legacyHooksPage.section2}> |
||||
<h3>Link extensions defined with configureExtensionLink and retrived using usePluginLinkExtensions</h3> |
||||
<ActionButton extensions={linkExtensions} /> |
||||
</section> |
||||
<section data-testid={testIds.legacyHooksPage.section3}> |
||||
<h3> |
||||
Component extensions defined with configureExtensionComponent and retrived using |
||||
usePluginComponentExtensions |
||||
</h3> |
||||
{componentExtensions.map((extension) => { |
||||
const Component = extension.component; |
||||
return <Component key={extension.id} name="World" />; |
||||
})} |
||||
</section> |
||||
</Stack> |
||||
</PluginPage> |
||||
); |
||||
} |
@ -1,5 +1,3 @@ |
||||
export { ExposedComponents } from './ExposedComponents'; |
||||
export { LegacyGetters } from './LegacyGetters'; |
||||
export { LegacyHooks } from './LegacyHooks'; |
||||
export { AddedComponents } from './AddedComponents'; |
||||
export { AddedLinks } from './AddedLinks'; |
||||
|
@ -1,52 +0,0 @@ |
||||
import { test, expect } from '@grafana/plugin-e2e'; |
||||
|
||||
import { ensureExtensionRegistryIsPopulated } from '../utils'; |
||||
import { testIds } from '../../testIds'; |
||||
import pluginJson from '../../plugin.json'; |
||||
|
||||
test.describe('getPluginExtensions + configureExtensionLink', () => { |
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-getters`); |
||||
await ensureExtensionRegistryIsPopulated(page); |
||||
const section = await page.getByTestId(testIds.legacyGettersPage.section1); |
||||
await section.getByTestId(testIds.actions.button).click(); |
||||
await page.getByTestId(testIds.container).getByText('Go to A').click(); |
||||
await page.getByTestId(testIds.modal.open).click(); |
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); |
||||
}); |
||||
}); |
||||
|
||||
test.describe('getPluginExtensions + configureExtensionComponent', () => { |
||||
test('should extend main app with component extension from app B', async ({ page }) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-getters`); |
||||
await ensureExtensionRegistryIsPopulated(page); |
||||
const section = await page.getByTestId(testIds.legacyGettersPage.section1); |
||||
await section.getByTestId(testIds.actions.button).click(); |
||||
await page.getByTestId(testIds.container).getByText('Open from B').click(); |
||||
await expect(page.getByTestId(testIds.appB.modal)).toBeVisible(); |
||||
}); |
||||
}); |
||||
|
||||
test.describe('getPluginLinkExtensions + configureExtensionLink', () => { |
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-getters`); |
||||
await ensureExtensionRegistryIsPopulated(page); |
||||
const section = await page.getByTestId(testIds.legacyGettersPage.section2); |
||||
await section.getByTestId(testIds.actions.button).click(); |
||||
await page.getByTestId(testIds.container).getByText('Go to A').click(); |
||||
await page.getByTestId(testIds.modal.open).click(); |
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); |
||||
}); |
||||
}); |
||||
|
||||
test.describe('getPluginComponentExtensions + configureExtensionComponent', () => { |
||||
test('should extend the actions menu with a command triggered from b-app plugin', async ({ page }) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-getters`); |
||||
await ensureExtensionRegistryIsPopulated(page); |
||||
await expect( |
||||
page |
||||
.getByTestId('configure-extension-component-get-plugin-component-extensions') |
||||
.getByTestId(testIds.appB.reusableComponent) |
||||
).toHaveText('Hello World!'); |
||||
}); |
||||
}); |
@ -1,67 +0,0 @@ |
||||
import { test, expect } from '@grafana/plugin-e2e'; |
||||
|
||||
import { testIds } from '../../testIds'; |
||||
import pluginJson from '../../plugin.json'; |
||||
import testApp3pluginJson from '../../plugins/grafana-extensionexample3-app/plugin.json'; |
||||
|
||||
test.describe('usePluginExtensions + configureExtensionLink', () => { |
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-hooks`); |
||||
const section = await page.getByTestId(testIds.legacyHooksPage.section1); |
||||
await section.getByTestId(testIds.actions.button).click(); |
||||
await page.getByTestId(testIds.container).getByText('Go to A').click(); |
||||
await page.getByTestId(testIds.modal.open).click(); |
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); |
||||
}); |
||||
|
||||
test('should not display extensions that have not been declared in plugin.json when in development mode', async ({ |
||||
page, |
||||
}) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-hooks`); |
||||
const section = await page.getByTestId(testIds.legacyHooksPage.section1); |
||||
await section.getByTestId(testIds.actions.button).click(); |
||||
await expect( |
||||
page.getByTestId(testIds.container).getByText('configureExtensionLink (where meta data is missing)') |
||||
).not.toBeVisible(); |
||||
}); |
||||
}); |
||||
|
||||
test.describe('usePluginExtensions + configureExtensionComponent', () => { |
||||
test('should extend main app with component extension from app B', async ({ page }) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-hooks`); |
||||
const section = await page.getByTestId(testIds.legacyHooksPage.section1); |
||||
await section.getByTestId(testIds.actions.button).click(); |
||||
await page.getByTestId(testIds.container).getByText('Open from B').click(); |
||||
await expect(page.getByTestId(testIds.appB.modal)).toBeVisible(); |
||||
}); |
||||
}); |
||||
|
||||
test.describe('usePluginLinkExtensions + configureExtensionLink', () => { |
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-hooks`); |
||||
const section = await page.getByTestId(testIds.legacyHooksPage.section2); |
||||
await section.getByTestId(testIds.actions.button).click(); |
||||
await page.getByTestId(testIds.container).getByText('Go to A').click(); |
||||
await page.getByTestId(testIds.modal.open).click(); |
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); |
||||
}); |
||||
}); |
||||
|
||||
test.describe('usePluginComponentExtensions + configureExtensionComponent', () => { |
||||
test('should extend the actions menu with a command triggered from b-app plugin', async ({ page }) => { |
||||
await page.goto(`/a/${pluginJson.id}/legacy-hooks`); |
||||
await expect( |
||||
page.getByTestId(testIds.legacyHooksPage.section3).getByTestId(testIds.appB.reusableComponent) |
||||
).toHaveText('Hello World!'); |
||||
}); |
||||
}); |
||||
|
||||
test.describe('usePluginExtensions + addLink', () => { |
||||
test('should not display extensions in case extension point has not been declared in plugin json (dev mode only)', async ({ |
||||
page, |
||||
}) => { |
||||
await page.goto(`/a/${testApp3pluginJson.id}/legacy-hooks`); |
||||
const section = await page.getByTestId(testIds.appC.section2); |
||||
await expect(section.getByTestId(testIds.actions.button)).not.toBeVisible(); |
||||
}); |
||||
}); |
@ -1,42 +0,0 @@ |
||||
import { expect, test } from '@grafana/plugin-e2e'; |
||||
import { ensureExtensionRegistryIsPopulated } from '../utils'; |
||||
|
||||
const panelTitle = 'Link with defaults'; |
||||
const extensionTitle = 'Open from time series...'; |
||||
|
||||
const linkOnClickDashboardUid = 'dbfb47c5-e5e5-4d28-8ac7-35f349b95946'; |
||||
const linkPathDashboardUid = 'd1fbb077-cd44-4738-8c8a-d4e66748b719'; |
||||
|
||||
test.describe('configureExtensionLink targeting core extension points', () => { |
||||
test('configureExtensionLink - should add link extension (path) with defaults to time series panel.', async ({ |
||||
gotoDashboardPage, |
||||
page, |
||||
}) => { |
||||
const dashboardPage = await gotoDashboardPage({ uid: linkPathDashboardUid }); |
||||
await ensureExtensionRegistryIsPopulated(page); |
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle); |
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' }); |
||||
await expect(page.getByRole('heading', { name: 'Extensions test app' })).toBeVisible(); |
||||
}); |
||||
|
||||
test('should add link extension (onclick) with defaults to time series panel', async ({ |
||||
gotoDashboardPage, |
||||
page, |
||||
}) => { |
||||
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid }); |
||||
await ensureExtensionRegistryIsPopulated(page); |
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle); |
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' }); |
||||
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with defaults"'); |
||||
}); |
||||
|
||||
test('should add link extension (onclick) with new title to pie chart panel', async ({ gotoDashboardPage, page }) => { |
||||
const panelTitle = 'Link with new name'; |
||||
const extensionTitle = 'Open from piechart'; |
||||
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid }); |
||||
await ensureExtensionRegistryIsPopulated(page); |
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle); |
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' }); |
||||
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with new name"'); |
||||
}); |
||||
}); |
@ -1,39 +0,0 @@ |
||||
import { setPluginExtensionGetter, type GetPluginExtensions, getPluginExtensions } from './getPluginExtensions'; |
||||
|
||||
describe('Plugin Extensions / Get Plugin Extensions', () => { |
||||
afterEach(() => { |
||||
process.env.NODE_ENV = 'test'; |
||||
}); |
||||
|
||||
test('should always return the same extension-getter function that was previously set', () => { |
||||
const getter: GetPluginExtensions = jest.fn().mockReturnValue({ extensions: [] }); |
||||
|
||||
setPluginExtensionGetter(getter); |
||||
getPluginExtensions({ extensionPointId: 'panel-menu' }); |
||||
|
||||
expect(getter).toHaveBeenCalledTimes(1); |
||||
expect(getter).toHaveBeenCalledWith({ extensionPointId: 'panel-menu' }); |
||||
}); |
||||
|
||||
test('should throw an error when trying to redefine the app-wide extension-getter function', () => { |
||||
// By default, NODE_ENV is set to 'test' in jest.config.js, which allows to override the registry in tests.
|
||||
process.env.NODE_ENV = 'production'; |
||||
|
||||
const getter: GetPluginExtensions = () => ({ extensions: [] }); |
||||
|
||||
expect(() => { |
||||
setPluginExtensionGetter(getter); |
||||
setPluginExtensionGetter(getter); |
||||
}).toThrowError(); |
||||
}); |
||||
|
||||
test('should throw an error when trying to access the extension-getter function before it was set', () => { |
||||
// "Unsetting" the registry
|
||||
// @ts-ignore
|
||||
setPluginExtensionGetter(undefined); |
||||
|
||||
expect(() => { |
||||
getPluginExtensions({ extensionPointId: 'panel-menu' }); |
||||
}).toThrowError(); |
||||
}); |
||||
}); |
@ -1,67 +0,0 @@ |
||||
import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data'; |
||||
|
||||
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; |
||||
|
||||
export type GetPluginExtensions<T = PluginExtension> = ( |
||||
options: GetPluginExtensionsOptions |
||||
) => GetPluginExtensionsResult<T>; |
||||
|
||||
export type UsePluginExtensions<T = PluginExtension> = ( |
||||
options: GetPluginExtensionsOptions |
||||
) => UsePluginExtensionsResult<T>; |
||||
|
||||
export type GetPluginExtensionsOptions = { |
||||
extensionPointId: string; |
||||
// Make sure this object is properly memoized and not mutated.
|
||||
context?: object | Record<string | symbol, unknown>; |
||||
limitPerPlugin?: number; |
||||
}; |
||||
|
||||
export type GetPluginExtensionsResult<T = PluginExtension> = { |
||||
extensions: T[]; |
||||
}; |
||||
|
||||
export type UsePluginExtensionsResult<T = PluginExtension> = { |
||||
extensions: T[]; |
||||
isLoading: boolean; |
||||
}; |
||||
|
||||
let singleton: GetPluginExtensions | undefined; |
||||
|
||||
export function setPluginExtensionGetter(instance: GetPluginExtensions): void { |
||||
// We allow overriding the registry in tests
|
||||
if (singleton && process.env.NODE_ENV !== 'test') { |
||||
throw new Error('setPluginExtensionGetter() function should only be called once, when Grafana is starting.'); |
||||
} |
||||
singleton = instance; |
||||
} |
||||
|
||||
function getPluginExtensionGetter(): GetPluginExtensions { |
||||
if (!singleton) { |
||||
throw new Error('getPluginExtensionGetter() can only be used after the Grafana instance has started.'); |
||||
} |
||||
return singleton; |
||||
} |
||||
|
||||
export const getPluginExtensions: GetPluginExtensions = (options) => getPluginExtensionGetter()(options); |
||||
|
||||
export const getPluginLinkExtensions: GetPluginExtensions<PluginExtensionLink> = (options) => { |
||||
const { extensions } = getPluginExtensions(options); |
||||
|
||||
return { |
||||
extensions: extensions.filter(isPluginExtensionLink), |
||||
}; |
||||
}; |
||||
|
||||
// This getter doesn't support the `context` option (contextual information can be passed in as component props)
|
||||
export const getPluginComponentExtensions = <Props = {}>(options: { |
||||
extensionPointId: string; |
||||
limitPerPlugin?: number; |
||||
}): { extensions: Array<PluginExtensionComponent<Props>> } => { |
||||
const { extensions } = getPluginExtensions(options); |
||||
const componentExtensions = extensions.filter(isPluginExtensionComponent) as Array<PluginExtensionComponent<Props>>; |
||||
|
||||
return { |
||||
extensions: componentExtensions, |
||||
}; |
||||
}; |
@ -1,262 +0,0 @@ |
||||
import { renderHook } from '@testing-library/react'; |
||||
|
||||
import { PluginExtension, PluginExtensionTypes } from '@grafana/data'; |
||||
|
||||
import { UsePluginExtensions } from './getPluginExtensions'; |
||||
import { |
||||
setPluginExtensionsHook, |
||||
usePluginComponentExtensions, |
||||
usePluginExtensions, |
||||
usePluginLinkExtensions, |
||||
} from './usePluginExtensions'; |
||||
|
||||
describe('Plugin Extensions / usePluginExtensions', () => { |
||||
afterEach(() => { |
||||
process.env.NODE_ENV = 'test'; |
||||
}); |
||||
|
||||
test('should always return the same extension-hook function that was previously set', () => { |
||||
const hook: UsePluginExtensions = jest.fn().mockReturnValue({ extensions: [], isLoading: false }); |
||||
|
||||
setPluginExtensionsHook(hook); |
||||
usePluginExtensions({ extensionPointId: 'panel-menu' }); |
||||
|
||||
expect(hook).toHaveBeenCalledTimes(1); |
||||
expect(hook).toHaveBeenCalledWith({ extensionPointId: 'panel-menu' }); |
||||
}); |
||||
|
||||
test('should throw an error when trying to redefine the app-wide extension-hook function', () => { |
||||
// By default, NODE_ENV is set to 'test' in jest.config.js, which allows to override the registry in tests.
|
||||
process.env.NODE_ENV = 'production'; |
||||
|
||||
const hook: UsePluginExtensions = () => ({ extensions: [], isLoading: false }); |
||||
|
||||
expect(() => { |
||||
setPluginExtensionsHook(hook); |
||||
setPluginExtensionsHook(hook); |
||||
}).toThrow(); |
||||
}); |
||||
|
||||
test('should throw an error when trying to access the extension-hook function before it was set', () => { |
||||
// "Unsetting" the registry
|
||||
// @ts-ignore
|
||||
setPluginExtensionsHook(undefined); |
||||
|
||||
expect(() => { |
||||
usePluginExtensions({ extensionPointId: 'panel-menu' }); |
||||
}).toThrow(); |
||||
}); |
||||
|
||||
describe('usePluginExtensionLinks()', () => { |
||||
test('should return only links extensions', () => { |
||||
const usePluginExtensionsMock: UsePluginExtensions = () => ({ |
||||
extensions: [ |
||||
{ |
||||
id: '1', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
type: PluginExtensionTypes.component, |
||||
component: () => undefined, |
||||
}, |
||||
{ |
||||
id: '2', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
path: '', |
||||
type: PluginExtensionTypes.link, |
||||
}, |
||||
{ |
||||
id: '3', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
path: '', |
||||
type: PluginExtensionTypes.link, |
||||
}, |
||||
], |
||||
isLoading: false, |
||||
}); |
||||
|
||||
setPluginExtensionsHook(usePluginExtensionsMock); |
||||
|
||||
const { result } = renderHook(() => usePluginLinkExtensions({ extensionPointId: 'panel-menu' })); |
||||
const { extensions } = result.current; |
||||
|
||||
expect(extensions).toHaveLength(2); |
||||
expect(extensions[0].type).toBe('link'); |
||||
expect(extensions[1].type).toBe('link'); |
||||
expect(extensions.find(({ id }) => id === '2')).toBeDefined(); |
||||
expect(extensions.find(({ id }) => id === '3')).toBeDefined(); |
||||
}); |
||||
|
||||
test('should return the same object if the extensions do not change', () => { |
||||
const extensionPointId = 'foo'; |
||||
const extensions: PluginExtension[] = [ |
||||
{ |
||||
id: '1', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
path: '', |
||||
type: PluginExtensionTypes.link, |
||||
}, |
||||
]; |
||||
|
||||
// Mimicing that the extensions do not change between renders
|
||||
const usePluginExtensionsMock: UsePluginExtensions = () => ({ |
||||
extensions, |
||||
isLoading: false, |
||||
}); |
||||
|
||||
setPluginExtensionsHook(usePluginExtensionsMock); |
||||
|
||||
const { result, rerender } = renderHook(() => usePluginLinkExtensions({ extensionPointId })); |
||||
const firstExtensions = result.current.extensions; |
||||
|
||||
rerender(); |
||||
|
||||
const secondExtensions = result.current.extensions; |
||||
|
||||
expect(firstExtensions === secondExtensions).toBe(true); |
||||
}); |
||||
|
||||
test('should return a different object if the extensions do change', () => { |
||||
const extensionPointId = 'foo'; |
||||
|
||||
// Mimicing that the extensions is a new array object every time
|
||||
const usePluginExtensionsMock: UsePluginExtensions = () => ({ |
||||
extensions: [ |
||||
{ |
||||
id: '1', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
path: '', |
||||
type: PluginExtensionTypes.link, |
||||
}, |
||||
], |
||||
isLoading: false, |
||||
}); |
||||
|
||||
setPluginExtensionsHook(usePluginExtensionsMock); |
||||
|
||||
const { result, rerender } = renderHook(() => usePluginLinkExtensions({ extensionPointId })); |
||||
const firstExtensions = result.current.extensions; |
||||
|
||||
rerender(); |
||||
|
||||
const secondExtensions = result.current.extensions; |
||||
|
||||
// The results differ
|
||||
expect(firstExtensions === secondExtensions).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('usePluginExtensionComponents()', () => { |
||||
test('should return only component extensions', () => { |
||||
const hook: UsePluginExtensions = () => ({ |
||||
extensions: [ |
||||
{ |
||||
id: '1', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
type: PluginExtensionTypes.component, |
||||
component: () => undefined, |
||||
}, |
||||
{ |
||||
id: '2', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
path: '', |
||||
type: PluginExtensionTypes.link, |
||||
}, |
||||
{ |
||||
id: '3', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
path: '', |
||||
type: PluginExtensionTypes.link, |
||||
}, |
||||
], |
||||
isLoading: false, |
||||
}); |
||||
|
||||
setPluginExtensionsHook(hook); |
||||
|
||||
const hookRender = renderHook(() => usePluginComponentExtensions({ extensionPointId: 'panel-menu' })); |
||||
const { extensions } = hookRender.result.current; |
||||
|
||||
expect(extensions).toHaveLength(1); |
||||
expect(extensions[0].type).toBe('component'); |
||||
expect(extensions.find(({ id }) => id === '1')).toBeDefined(); |
||||
}); |
||||
|
||||
test('should return the same object if the extensions do not change', () => { |
||||
const extensionPointId = 'foo'; |
||||
const extensions: PluginExtension[] = [ |
||||
{ |
||||
id: '1', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
type: PluginExtensionTypes.component, |
||||
component: () => undefined, |
||||
}, |
||||
]; |
||||
|
||||
// Mimicing that the extensions do not change between renders
|
||||
const usePluginExtensionsMock: UsePluginExtensions = () => ({ |
||||
extensions, |
||||
isLoading: false, |
||||
}); |
||||
|
||||
setPluginExtensionsHook(usePluginExtensionsMock); |
||||
|
||||
const { result, rerender } = renderHook(() => usePluginComponentExtensions({ extensionPointId })); |
||||
const firstExtensions = result.current.extensions; |
||||
|
||||
rerender(); |
||||
|
||||
const secondExtensions = result.current.extensions; |
||||
|
||||
// The results are the same
|
||||
expect(firstExtensions === secondExtensions).toBe(true); |
||||
}); |
||||
|
||||
test('should return a different object if the extensions do change', () => { |
||||
const extensionPointId = 'foo'; |
||||
|
||||
// Mimicing that the extensions is a new array object every time
|
||||
const usePluginExtensionsMock: UsePluginExtensions = () => ({ |
||||
extensions: [ |
||||
{ |
||||
id: '1', |
||||
pluginId: '', |
||||
title: '', |
||||
description: '', |
||||
type: PluginExtensionTypes.component, |
||||
component: () => undefined, |
||||
}, |
||||
], |
||||
isLoading: false, |
||||
}); |
||||
|
||||
setPluginExtensionsHook(usePluginExtensionsMock); |
||||
|
||||
const { result, rerender } = renderHook(() => usePluginComponentExtensions({ extensionPointId })); |
||||
const firstExtensions = result.current.extensions; |
||||
|
||||
rerender(); |
||||
|
||||
const secondExtensions = result.current.extensions; |
||||
|
||||
// The results differ
|
||||
expect(firstExtensions === secondExtensions).toBe(false); |
||||
}); |
||||
}); |
||||
}); |
@ -1,59 +0,0 @@ |
||||
import { useMemo } from 'react'; |
||||
|
||||
import { PluginExtensionComponent, PluginExtensionLink } from '@grafana/data'; |
||||
|
||||
import { GetPluginExtensionsOptions, UsePluginExtensions, UsePluginExtensionsResult } from './getPluginExtensions'; |
||||
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; |
||||
|
||||
let singleton: UsePluginExtensions | undefined; |
||||
|
||||
export function setPluginExtensionsHook(hook: UsePluginExtensions): void { |
||||
// We allow overriding the registry in tests
|
||||
if (singleton && process.env.NODE_ENV !== 'test') { |
||||
throw new Error('setPluginExtensionsHook() function should only be called once, when Grafana is starting.'); |
||||
} |
||||
singleton = hook; |
||||
} |
||||
|
||||
/** |
||||
* @deprecated Use either usePluginLinks() or usePluginComponents() instead. |
||||
*/ |
||||
export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult { |
||||
if (!singleton) { |
||||
throw new Error('usePluginExtensions(options) can only be used after the Grafana instance has started.'); |
||||
} |
||||
return singleton(options); |
||||
} |
||||
|
||||
/** |
||||
* @deprecated Use usePluginLinks() instead. |
||||
*/ |
||||
export function usePluginLinkExtensions( |
||||
options: GetPluginExtensionsOptions |
||||
): UsePluginExtensionsResult<PluginExtensionLink> { |
||||
const { extensions, isLoading } = usePluginExtensions(options); |
||||
|
||||
return useMemo(() => { |
||||
return { |
||||
extensions: extensions.filter(isPluginExtensionLink), |
||||
isLoading, |
||||
}; |
||||
}, [extensions, isLoading]); |
||||
} |
||||
|
||||
/** |
||||
* @deprecated Use usePluginComponents() instead. |
||||
*/ |
||||
export function usePluginComponentExtensions<Props = {}>( |
||||
options: GetPluginExtensionsOptions |
||||
): { extensions: Array<PluginExtensionComponent<Props>>; isLoading: boolean } { |
||||
const { extensions, isLoading } = usePluginExtensions(options); |
||||
|
||||
return useMemo( |
||||
() => ({ |
||||
extensions: extensions.filter(isPluginExtensionComponent) as Array<PluginExtensionComponent<Props>>, |
||||
isLoading, |
||||
}), |
||||
[extensions, isLoading] |
||||
); |
||||
} |
@ -1,273 +0,0 @@ |
||||
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'; |
||||
import { useLoadAppPlugins } from './useLoadAppPlugins'; |
||||
import { createUsePluginExtensions } from './usePluginExtensions'; |
||||
|
||||
jest.mock('./useLoadAppPlugins'); |
||||
|
||||
describe('usePluginExtensions()', () => { |
||||
let registries: PluginExtensionRegistries; |
||||
const pluginId = 'myorg-extensions-app'; |
||||
const extensionPointId = `${pluginId}/extension-point/v1`; |
||||
|
||||
beforeEach(() => { |
||||
registries = { |
||||
addedComponentsRegistry: new AddedComponentsRegistry(), |
||||
addedLinksRegistry: new AddedLinksRegistry(), |
||||
exposedComponentsRegistry: new ExposedComponentsRegistry(), |
||||
addedFunctionsRegistry: new AddedFunctionsRegistry(), |
||||
}; |
||||
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); |
||||
}); |
||||
|
||||
it('should return an empty array if there are no extensions registered for the extension point', () => { |
||||
const usePluginExtensions = createUsePluginExtensions(registries); |
||||
const { result } = renderHook(() => |
||||
usePluginExtensions({ |
||||
extensionPointId: 'foo/bar/v1', |
||||
}) |
||||
); |
||||
|
||||
expect(result.current.extensions).toEqual([]); |
||||
}); |
||||
|
||||
it('should return the plugin link extensions from the registry', () => { |
||||
registries.addedLinksRegistry.register({ |
||||
pluginId, |
||||
configs: [ |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '1', |
||||
description: '1', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '2', |
||||
description: '2', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
const usePluginExtensions = createUsePluginExtensions(registries); |
||||
const { result } = renderHook(() => usePluginExtensions({ extensionPointId })); |
||||
|
||||
expect(result.current.extensions.length).toBe(2); |
||||
expect(result.current.extensions[0].title).toBe('1'); |
||||
expect(result.current.extensions[1].title).toBe('2'); |
||||
}); |
||||
|
||||
it('should return the plugin component extensions from the registry', () => { |
||||
const componentExtensionPointId = `${pluginId}/component/v1`; |
||||
|
||||
registries.addedLinksRegistry.register({ |
||||
pluginId, |
||||
configs: [ |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '1', |
||||
description: '1', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '2', |
||||
description: '2', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
registries.addedComponentsRegistry.register({ |
||||
pluginId, |
||||
configs: [ |
||||
{ |
||||
targets: componentExtensionPointId, |
||||
title: 'Component 1', |
||||
description: '1', |
||||
component: () => <div>Hello World1</div>, |
||||
}, |
||||
{ |
||||
targets: componentExtensionPointId, |
||||
title: 'Component 2', |
||||
description: '2', |
||||
component: () => <div>Hello World2</div>, |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
const usePluginExtensions = createUsePluginExtensions(registries); |
||||
const { result } = renderHook(() => usePluginExtensions({ extensionPointId: componentExtensionPointId })); |
||||
|
||||
expect(result.current.extensions.length).toBe(2); |
||||
expect(result.current.extensions[0].title).toBe('Component 1'); |
||||
expect(result.current.extensions[1].title).toBe('Component 2'); |
||||
}); |
||||
|
||||
it('should dynamically update the extensions registered for a certain extension point', () => { |
||||
const usePluginExtensions = createUsePluginExtensions(registries); |
||||
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId })); |
||||
|
||||
// No extensions yet
|
||||
expect(result.current.extensions.length).toBe(0); |
||||
|
||||
// Add extensions to the registry
|
||||
act(() => { |
||||
registries.addedLinksRegistry.register({ |
||||
pluginId, |
||||
configs: [ |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '1', |
||||
description: '1', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '2', |
||||
description: '2', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
// Check if the hook returns the new extensions
|
||||
rerender(); |
||||
|
||||
expect(result.current.extensions.length).toBe(2); |
||||
expect(result.current.extensions[0].title).toBe('1'); |
||||
expect(result.current.extensions[1].title).toBe('2'); |
||||
}); |
||||
|
||||
it('should only render the hook once', () => { |
||||
const addedComponentsRegistrySpy = jest.spyOn(registries.addedComponentsRegistry, 'asObservable'); |
||||
const addedLinksRegistrySpy = jest.spyOn(registries.addedLinksRegistry, 'asObservable'); |
||||
const usePluginExtensions = createUsePluginExtensions(registries); |
||||
|
||||
renderHook(() => usePluginExtensions({ extensionPointId })); |
||||
expect(addedComponentsRegistrySpy).toHaveBeenCalledTimes(1); |
||||
expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1); |
||||
}); |
||||
|
||||
it('should return the same extensions object if the context object is the same', async () => { |
||||
const usePluginExtensions = createUsePluginExtensions(registries); |
||||
|
||||
// Add extensions to the registry
|
||||
act(() => { |
||||
registries.addedLinksRegistry.register({ |
||||
pluginId, |
||||
configs: [ |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '1', |
||||
description: '1', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '2', |
||||
description: '2', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
// Check if it returns the same extensions object in case nothing changes
|
||||
const context = {}; |
||||
const { rerender, result } = renderHook(usePluginExtensions, { |
||||
initialProps: { |
||||
extensionPointId, |
||||
context, |
||||
}, |
||||
}); |
||||
const firstResult = result.current; |
||||
|
||||
rerender({ context, extensionPointId }); |
||||
const secondResult = result.current; |
||||
|
||||
expect(firstResult.extensions).toBe(secondResult.extensions); |
||||
}); |
||||
|
||||
it('should return a new extensions object if the context object is different', () => { |
||||
const usePluginExtensions = createUsePluginExtensions(registries); |
||||
|
||||
// Add extensions to the registry
|
||||
act(() => { |
||||
registries.addedLinksRegistry.register({ |
||||
pluginId, |
||||
configs: [ |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '1', |
||||
description: '1', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '2', |
||||
description: '2', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
// Check if it returns a different extensions object in case the context object changes
|
||||
const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context: {} })); |
||||
const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context: {} })); |
||||
expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(false); |
||||
}); |
||||
|
||||
it('should return a new extensions object if the registry changes but the context object is the same', () => { |
||||
const context = {}; |
||||
const usePluginExtensions = createUsePluginExtensions(registries); |
||||
|
||||
// Add the first extension
|
||||
act(() => { |
||||
registries.addedLinksRegistry.register({ |
||||
pluginId, |
||||
configs: [ |
||||
{ |
||||
targets: extensionPointId, |
||||
title: '1', |
||||
description: '1', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
const { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId, context })); |
||||
const firstExtensions = result.current.extensions; |
||||
|
||||
// Add the second extension
|
||||
act(() => { |
||||
registries.addedLinksRegistry.register({ |
||||
pluginId, |
||||
configs: [ |
||||
{ |
||||
targets: extensionPointId, |
||||
// extensionPointId: 'plugins/foo/bar/zed', // A different extension point (to be sure that it's also returning a new object when the actual extension point doesn't change)
|
||||
title: '2', |
||||
description: '2', |
||||
path: `/a/${pluginId}/2`, |
||||
}, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
rerender(); |
||||
|
||||
const secondExtensions = result.current.extensions; |
||||
|
||||
expect(firstExtensions === secondExtensions).toBe(false); |
||||
}); |
||||
}); |
@ -1,86 +0,0 @@ |
||||
import { useMemo } from 'react'; |
||||
import { useObservable } from 'react-use'; |
||||
|
||||
import { PluginExtension, usePluginContext } from '@grafana/data'; |
||||
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime'; |
||||
|
||||
import * as errors from './errors'; |
||||
import { getPluginExtensions } from './getPluginExtensions'; |
||||
import { log } from './logs/log'; |
||||
import { PluginExtensionRegistries } from './registry/types'; |
||||
import { useLoadAppPlugins } from './useLoadAppPlugins'; |
||||
import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; |
||||
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; |
||||
|
||||
export function createUsePluginExtensions(registries: PluginExtensionRegistries) { |
||||
const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable(); |
||||
const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable(); |
||||
|
||||
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> { |
||||
const pluginContext = usePluginContext(); |
||||
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry); |
||||
const addedLinksRegistry = useObservable(observableAddedLinksRegistry); |
||||
const { extensionPointId, context, limitPerPlugin } = options; |
||||
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); |
||||
|
||||
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 !== null; |
||||
const pluginId = pluginContext?.meta.id ?? ''; |
||||
const pointLog = log.child({ |
||||
pluginId, |
||||
extensionPointId, |
||||
}); |
||||
|
||||
if (!addedLinksRegistry && !addedComponentsRegistry) { |
||||
return { extensions: [], isLoading: false }; |
||||
} |
||||
|
||||
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { |
||||
pointLog.error(errors.INVALID_EXTENSION_POINT_ID); |
||||
return { |
||||
isLoading: false, |
||||
extensions: [], |
||||
}; |
||||
} |
||||
|
||||
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { |
||||
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); |
||||
return { |
||||
isLoading: false, |
||||
extensions: [], |
||||
}; |
||||
} |
||||
|
||||
if (isLoadingAppPlugins) { |
||||
return { |
||||
isLoading: true, |
||||
extensions: [], |
||||
}; |
||||
} |
||||
|
||||
const { extensions } = getPluginExtensions({ |
||||
extensionPointId, |
||||
context, |
||||
limitPerPlugin, |
||||
addedComponentsRegistry, |
||||
addedLinksRegistry, |
||||
}); |
||||
|
||||
return { extensions, isLoading: false }; |
||||
|
||||
// Doing the deps like this instead of just `option` because users probably aren't going to memoize the
|
||||
// options object so we are checking it's simple value attributes.
|
||||
// The context though still has to be memoized though and not mutated.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: refactor `getPluginExtensions` to accept service dependencies as arguments instead of relying on the sidecar singleton under the hood
|
||||
}, [ |
||||
addedLinksRegistry, |
||||
addedComponentsRegistry, |
||||
extensionPointId, |
||||
context, |
||||
limitPerPlugin, |
||||
pluginContext, |
||||
isLoadingAppPlugins, |
||||
]); |
||||
}; |
||||
} |
Loading…
Reference in new issue