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 commit53aa767664. * 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>
This commit is contained in:
co-authored by
Hugo Häggmark
parent
c1cadc7d6f
commit
39dcff23f9
@@ -489,18 +489,12 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"]
|
||||
],
|
||||
"packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"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"]
|
||||
],
|
||||
|
||||
@@ -3,20 +3,18 @@ import { Route, Routes } from 'react-router-dom';
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
|
||||
import { ROUTES } from '../../constants';
|
||||
import { AddedComponents, AddedLinks, ExposedComponents, LegacyGetters, LegacyHooks } from '../../pages';
|
||||
import { AddedComponents, AddedLinks, ExposedComponents } from '../../pages';
|
||||
import { testIds } from '../../testIds';
|
||||
|
||||
export function App(props: AppRootProps) {
|
||||
return (
|
||||
<div data-testid={testIds.container} style={{ marginTop: '5%' }}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.LegacyGetters} element={<LegacyGetters />} />
|
||||
<Route path={ROUTES.LegacyHooks} element={<LegacyHooks />} />
|
||||
<Route path={ROUTES.ExposedComponents} element={<ExposedComponents />} />
|
||||
<Route path={ROUTES.AddedComponents} element={<AddedComponents />} />
|
||||
<Route path={ROUTES.AddedLinks} element={<AddedLinks />} />
|
||||
|
||||
<Route path={'*'} element={<LegacyGetters />} />
|
||||
<Route path={'*'} element={<ExposedComponents />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,10 +6,10 @@ import pluginJson from './plugin.json';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.configureExtensionLink<PluginExtensionPanelContext>({
|
||||
.addLink<PluginExtensionPanelContext>({
|
||||
title: 'Open from time series or pie charts (path)',
|
||||
description: 'This link will only be visible on time series and pie charts',
|
||||
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
||||
targets: PluginExtensionPoints.DashboardPanelMenu,
|
||||
path: `/a/${pluginJson.id}/`,
|
||||
configure: (context) => {
|
||||
// Will only be visible for the Link Extensions dashboard
|
||||
@@ -31,10 +31,10 @@ export const plugin = new AppPlugin<{}>()
|
||||
}
|
||||
},
|
||||
})
|
||||
.configureExtensionLink<PluginExtensionPanelContext>({
|
||||
.addLink<PluginExtensionPanelContext>({
|
||||
title: 'Open from time series or pie charts (onClick)',
|
||||
description: 'This link will only be visible on time series and pie charts',
|
||||
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
||||
targets: PluginExtensionPoints.DashboardPanelMenu,
|
||||
onClick: (_, { openModal, context }) => {
|
||||
const targets = context?.targets ?? [];
|
||||
const title = context?.title;
|
||||
|
||||
@@ -42,6 +42,5 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@grafana/runtime": "*"
|
||||
},
|
||||
"packageManager": "yarn@4.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -19,22 +19,6 @@
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Legacy Getters",
|
||||
"path": "/a/grafana-extensionstest-app/legacy-getters",
|
||||
"role": "Admin",
|
||||
"addToNav": true,
|
||||
"defaultNav": false
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Legacy Hooks",
|
||||
"path": "/a/grafana-extensionstest-app/legacy-hooks",
|
||||
"role": "Admin",
|
||||
"addToNav": true,
|
||||
"defaultNav": false
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Exposed components",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ export class App extends React.PureComponent<AppRootProps> {
|
||||
render() {
|
||||
return (
|
||||
<div data-testid={testIds.appA.container} className="page-container">
|
||||
Hello Grafana!
|
||||
Hello Grafana!!!!!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-6
@@ -7,12 +7,6 @@ import { App } from './components/App';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.configureExtensionLink({
|
||||
title: 'Go to A',
|
||||
description: 'Navigating to pluging A',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
|
||||
path: '/a/grafana-extensionexample1-app/',
|
||||
})
|
||||
.exposeComponent({
|
||||
id: 'grafana-extensionexample1-app/reusable-component/v1',
|
||||
title: 'Exposed component',
|
||||
|
||||
-17
@@ -7,23 +7,6 @@ import { App } from './components/App';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.configureExtensionLink({
|
||||
title: 'Open from B',
|
||||
description: 'Open a modal from plugin B',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app B',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
})
|
||||
.configureExtensionComponent({
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
|
||||
title: 'Configure extension component from B',
|
||||
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
|
||||
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
|
||||
})
|
||||
.addComponent<{ name: string }>({
|
||||
targets: 'plugins/grafana-extensionstest-app/addComponent/v1',
|
||||
title: 'Added component from B',
|
||||
|
||||
+1
-7
@@ -1,4 +1,4 @@
|
||||
import { usePluginExtensions, usePluginLinks } from '@grafana/runtime';
|
||||
import { usePluginLinks } from '@grafana/runtime';
|
||||
import { Stack } from '@grafana/ui';
|
||||
import { testIds } from '../../../../testIds';
|
||||
import { ActionButton } from '../../../../components/ActionButton';
|
||||
@@ -7,18 +7,12 @@ export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-
|
||||
|
||||
export function AddedLinks() {
|
||||
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||
const { extensions } = usePluginExtensions({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||
|
||||
return (
|
||||
<Stack direction={'column'} gap={4}>
|
||||
<section data-testid={testIds.appC.section1}>
|
||||
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
|
||||
<ActionButton extensions={links} />
|
||||
</section>
|
||||
<section data-testid={testIds.appC.section2}>
|
||||
<h3>Link extensions defined with addLink and retrieved using usePluginExtensions</h3>
|
||||
<ActionButton extensions={extensions} />
|
||||
</section>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
-17
@@ -6,23 +6,6 @@ import { App } from '../../components/App';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.configureExtensionLink({
|
||||
title: 'configureExtensionLink (where meta data is missing)',
|
||||
description: 'Open a modal from plugin B',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app B',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
})
|
||||
.configureExtensionComponent({
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
|
||||
title: 'configureExtensionComponent (where meta data is missing)',
|
||||
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
|
||||
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
|
||||
})
|
||||
.addComponent<{ name: string }>({
|
||||
targets: ['plugins/grafana-extensionstest-app/addComponent/v1'],
|
||||
title: 'Added component (where meta data is missing)',
|
||||
|
||||
-52
@@ -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!');
|
||||
});
|
||||
});
|
||||
-67
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -37,3 +37,35 @@ test('should not display any extensions when extension point is not declared in
|
||||
const container = await page.getByTestId(testIds.appC.section1);
|
||||
await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible();
|
||||
});
|
||||
|
||||
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('configureExtensionLink - should add link extension (path) with defaults to time series panel.', async ({
|
||||
gotoDashboardPage,
|
||||
page,
|
||||
}) => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: linkPathDashboardUid });
|
||||
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 });
|
||||
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 });
|
||||
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"');
|
||||
});
|
||||
|
||||
@@ -552,10 +552,7 @@ export {
|
||||
type PluginExtensionComponent,
|
||||
type PluginExtensionComponentMeta,
|
||||
type ComponentTypeWithExtensionMeta,
|
||||
type PluginExtensionConfig,
|
||||
type PluginExtensionFunction,
|
||||
type PluginExtensionLinkConfig,
|
||||
type PluginExtensionComponentConfig,
|
||||
type PluginExtensionEventHelpers,
|
||||
type PluginExtensionPanelContext,
|
||||
type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context,
|
||||
|
||||
@@ -6,8 +6,6 @@ import { KeyValue } from './data';
|
||||
import { NavModel } from './navModel';
|
||||
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
||||
import {
|
||||
type PluginExtensionLinkConfig,
|
||||
PluginExtensionComponentConfig,
|
||||
PluginExtensionExposedComponentConfig,
|
||||
PluginExtensionAddedComponentConfig,
|
||||
PluginExtensionAddedLinkConfig,
|
||||
@@ -142,26 +140,6 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @deprecated Use .addLink() instead */
|
||||
configureExtensionLink<Context extends object>(extension: Omit<PluginExtensionLinkConfig<Context>, 'type'>) {
|
||||
this.addLink({
|
||||
targets: [extension.extensionPointId],
|
||||
...extension,
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
/** @deprecated Use .addComponent() instead */
|
||||
configureExtensionComponent<Props = {}>(extension: Omit<PluginExtensionComponentConfig<Props>, 'type'>) {
|
||||
this.addComponent({
|
||||
targets: [extension.extensionPointId],
|
||||
...extension,
|
||||
component: extension.component,
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -152,8 +152,6 @@ export type PluginExtensionExposedComponentConfig<Props = {}> = PluginExtensionC
|
||||
component: React.ComponentType<Props>;
|
||||
};
|
||||
|
||||
export type PluginExtensionConfig = PluginExtensionLinkConfig | PluginExtensionComponentConfig;
|
||||
|
||||
export type PluginExtensionOpenModalOptions = {
|
||||
// The title of the modal
|
||||
title: string;
|
||||
@@ -236,61 +234,3 @@ type Dashboard = {
|
||||
title: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
// deprecated types
|
||||
|
||||
/** @deprecated - use PluginExtensionAddedLinkConfig instead */
|
||||
export type PluginExtensionLinkConfig<Context extends object = object> = {
|
||||
type: PluginExtensionTypes.link;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
// A URL path that will be used as the href for the rendered link extension
|
||||
// (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page)
|
||||
path?: string;
|
||||
|
||||
// A function that will be called when the link is clicked
|
||||
// (It is called with the original event object)
|
||||
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
|
||||
|
||||
/**
|
||||
* The unique identifier of the Extension Point
|
||||
* (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
|
||||
*/
|
||||
extensionPointId: string;
|
||||
|
||||
// (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
|
||||
configure?: (context?: Readonly<Context>) =>
|
||||
| Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
|
||||
icon: IconName;
|
||||
category: string;
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
// (Optional) A icon that can be displayed in the ui for the extension option.
|
||||
icon?: IconName;
|
||||
|
||||
// (Optional) A category to be used when grouping the options in the ui
|
||||
category?: string;
|
||||
};
|
||||
|
||||
/** @deprecated - use PluginAddedLinkConfig instead */
|
||||
export type PluginExtensionComponentConfig<Props = {}> = {
|
||||
type: PluginExtensionTypes.component;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
// The React component that will be rendered as the extension
|
||||
// (This component receives contextual information as props when it is rendered. You can just return `null` from the component to hide it.)
|
||||
component: React.ComponentType<Props>;
|
||||
|
||||
/**
|
||||
* The unique identifier of the Extension Point
|
||||
* (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
|
||||
*/
|
||||
extensionPointId: string;
|
||||
};
|
||||
|
||||
@@ -9,24 +9,6 @@ export * from './appEvents';
|
||||
export * from './SidecarService_EXPERIMENTAL';
|
||||
export * from './SidecarContext_EXPERIMENTAL';
|
||||
|
||||
export {
|
||||
setPluginExtensionGetter,
|
||||
getPluginExtensions,
|
||||
getPluginLinkExtensions,
|
||||
getPluginComponentExtensions,
|
||||
type GetPluginExtensions,
|
||||
type GetPluginExtensionsOptions,
|
||||
type GetPluginExtensionsResult,
|
||||
type UsePluginExtensions,
|
||||
type UsePluginExtensionsResult,
|
||||
} from './pluginExtensions/getPluginExtensions';
|
||||
export {
|
||||
setPluginExtensionsHook,
|
||||
usePluginExtensions,
|
||||
usePluginLinkExtensions,
|
||||
usePluginComponentExtensions,
|
||||
} from './pluginExtensions/usePluginExtensions';
|
||||
|
||||
export {
|
||||
setPluginComponentHook,
|
||||
usePluginComponent,
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -28,11 +28,9 @@ import {
|
||||
setQueryRunnerFactory,
|
||||
setRunRequest,
|
||||
setPluginImportUtils,
|
||||
setPluginExtensionGetter,
|
||||
setEmbeddedDashboard,
|
||||
setAppEvents,
|
||||
setReturnToPreviousHook,
|
||||
setPluginExtensionsHook,
|
||||
setPluginComponentHook,
|
||||
setPluginComponentsHook,
|
||||
setCurrentUser,
|
||||
@@ -83,14 +81,11 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi
|
||||
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
||||
import { DatasourceSrv } from './features/plugins/datasource_srv';
|
||||
import {
|
||||
createPluginExtensionsGetter,
|
||||
getObservablePluginComponents,
|
||||
getObservablePluginLinks,
|
||||
} from './features/plugins/extensions/getPluginExtensions';
|
||||
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
|
||||
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';
|
||||
@@ -226,8 +221,6 @@ export class GrafanaApp {
|
||||
await preloadPlugins(appPluginsToAwait);
|
||||
}
|
||||
|
||||
setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionRegistries));
|
||||
setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionRegistries));
|
||||
setPluginLinksHook(usePluginLinks);
|
||||
setPluginComponentHook(usePluginComponent);
|
||||
setPluginComponentsHook(usePluginComponents);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data';
|
||||
import { AppPluginConfig, setPluginComponentsHook, setPluginExtensionsHook } from '@grafana/runtime';
|
||||
import { AppPluginConfig, setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
|
||||
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
|
||||
|
||||
import { mockPluginLinkExtension } from '../mocks';
|
||||
|
||||
export function setupPluginsExtensionsHook() {
|
||||
setPluginExtensionsHook(() => ({
|
||||
extensions: plugins.map((plugin) =>
|
||||
setPluginLinksHook(() => ({
|
||||
links: plugins.map((plugin) =>
|
||||
mockPluginLinkExtension({
|
||||
pluginId: plugin.id,
|
||||
title: plugin.name,
|
||||
|
||||
@@ -8,13 +8,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
import { PanelProps, systemDateFormats, SystemDateFormatsState } from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import {
|
||||
LocationServiceProvider,
|
||||
config,
|
||||
getPluginLinkExtensions,
|
||||
locationService,
|
||||
setPluginImportUtils,
|
||||
} from '@grafana/runtime';
|
||||
import { LocationServiceProvider, config, locationService, setPluginImportUtils } from '@grafana/runtime';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||
@@ -32,7 +26,6 @@ import { getDashboardScenePageStateManager } from './DashboardScenePageStateMana
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
useChromeHeaderHeight: jest.fn().mockReturnValue(80),
|
||||
getBackendSrv: () => {
|
||||
return {
|
||||
@@ -55,7 +48,11 @@ jest.mock('react-router-dom-v5-compat', () => ({
|
||||
useParams: jest.fn().mockReturnValue({ uid: 'my-dash-uid' }),
|
||||
}));
|
||||
|
||||
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
|
||||
const getPluginExtensionsMock = jest.fn().mockReturnValue({ extensions: [] });
|
||||
jest.mock('app/features/plugins/extensions/getPluginExtensions', () => ({
|
||||
...jest.requireActual('app/features/plugins/extensions/getPluginExtensions'),
|
||||
createPluginExtensionsGetter: () => getPluginExtensionsMock,
|
||||
}));
|
||||
|
||||
function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps> } = {}) {
|
||||
const context = getGrafanaContextMock();
|
||||
@@ -156,8 +153,8 @@ describe('DashboardScenePage', () => {
|
||||
// hacky way because mocking autosizer does not work
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
|
||||
getPluginLinkExtensionsMock.mockRestore();
|
||||
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||
getPluginExtensionsMock.mockRestore();
|
||||
getPluginExtensionsMock.mockReturnValue({ extensions: [] });
|
||||
store.delete(DASHBOARD_FROM_LS_KEY);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { render } from 'test/test-utils';
|
||||
import { getDefaultTimeRange, LoadingState, PanelData, PanelProps } from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config, getPluginLinkExtensions, setPluginImportUtils, setRunRequest } from '@grafana/runtime';
|
||||
import { config, setPluginImportUtils, setRunRequest } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||
import { DashboardRoutes } from 'app/types/dashboard';
|
||||
@@ -18,8 +18,6 @@ import { PublicDashboardScenePage, Props as PublicDashboardSceneProps } from './
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
getDataSourceSrv: () => {
|
||||
return {
|
||||
get: jest.fn().mockResolvedValue({}),
|
||||
@@ -28,8 +26,6 @@ jest.mock('@grafana/runtime', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
|
||||
|
||||
function setup(token = 'an-access-token') {
|
||||
const pubdashProps: PublicDashboardSceneProps = {
|
||||
...getRouteComponentProps({
|
||||
@@ -126,8 +122,6 @@ describe('PublicDashboardScenePage', () => {
|
||||
// // hacky way because mocking autosizer does not work
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
|
||||
getPluginLinkExtensionsMock.mockRestore();
|
||||
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||
});
|
||||
|
||||
it('can render public dashboard', async () => {
|
||||
|
||||
+1
-6
@@ -18,7 +18,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config, locationService, setPluginExtensionsHook } from '@grafana/runtime';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { PANEL_EDIT_LAST_USED_DATASOURCE } from 'app/features/dashboard/utils/dashboard';
|
||||
import { InspectTab } from 'app/features/inspector/types';
|
||||
import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants';
|
||||
@@ -56,11 +56,6 @@ async function createModelMock() {
|
||||
return queriesTab;
|
||||
}
|
||||
|
||||
setPluginExtensionsHook(() => ({
|
||||
extensions: [],
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
|
||||
const result: PanelData = {
|
||||
state: LoadingState.Loading,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
urlUtil,
|
||||
} from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import {
|
||||
LocalValueVariable,
|
||||
SceneQueryRunner,
|
||||
@@ -47,24 +47,17 @@ jest.mock('app/core/utils/explore', () => ({
|
||||
|
||||
jest.mock('app/core/services/context_srv');
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('app/store/store', () => ({
|
||||
dispatch: jest.fn(),
|
||||
}));
|
||||
|
||||
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
|
||||
const getPluginExtensionsMock = jest.fn().mockReturnValue({ extensions: [] });
|
||||
jest.mock('app/features/plugins/extensions/getPluginExtensions', () => ({
|
||||
...jest.requireActual('app/features/plugins/extensions/getPluginExtensions'),
|
||||
createPluginExtensionsGetter: () => getPluginExtensionsMock,
|
||||
}));
|
||||
|
||||
describe('panelMenuBehavior', () => {
|
||||
beforeEach(() => {
|
||||
getPluginLinkExtensionsMock.mockRestore();
|
||||
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
locationService.push('/d/dash-1?from=now-5m&to=now');
|
||||
});
|
||||
@@ -133,7 +126,7 @@ describe('panelMenuBehavior', () => {
|
||||
|
||||
describe('when extending panel menu from plugins', () => {
|
||||
it('should contain menu item from link extension', async () => {
|
||||
getPluginLinkExtensionsMock.mockReturnValue({
|
||||
getPluginExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
id: '1',
|
||||
@@ -172,7 +165,7 @@ describe('panelMenuBehavior', () => {
|
||||
});
|
||||
|
||||
it('should truncate menu item title to 25 chars', async () => {
|
||||
getPluginLinkExtensionsMock.mockReturnValue({
|
||||
getPluginExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
id: '1',
|
||||
@@ -213,7 +206,7 @@ describe('panelMenuBehavior', () => {
|
||||
it('should pass onClick from plugin extension link to menu item', async () => {
|
||||
const expectedOnClick = jest.fn();
|
||||
|
||||
getPluginLinkExtensionsMock.mockReturnValue({
|
||||
getPluginExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
id: '1',
|
||||
@@ -299,7 +292,7 @@ describe('panelMenuBehavior', () => {
|
||||
data,
|
||||
};
|
||||
|
||||
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
|
||||
expect(getPluginExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
|
||||
});
|
||||
|
||||
it('should pass context with default time zone values when configuring extension', async () => {
|
||||
@@ -356,11 +349,11 @@ describe('panelMenuBehavior', () => {
|
||||
data,
|
||||
};
|
||||
|
||||
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
|
||||
expect(getPluginExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
|
||||
});
|
||||
|
||||
it('should contain menu item with category', async () => {
|
||||
getPluginLinkExtensionsMock.mockReturnValue({
|
||||
getPluginExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
id: '1',
|
||||
@@ -405,7 +398,7 @@ describe('panelMenuBehavior', () => {
|
||||
});
|
||||
|
||||
it('should truncate category to 25 chars', async () => {
|
||||
getPluginLinkExtensionsMock.mockReturnValue({
|
||||
getPluginExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
id: '1',
|
||||
@@ -450,7 +443,7 @@ describe('panelMenuBehavior', () => {
|
||||
});
|
||||
|
||||
it('should contain menu item with category and append items without category after divider', async () => {
|
||||
getPluginLinkExtensionsMock.mockReturnValue({
|
||||
getPluginExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
id: '1',
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
PanelPlugin,
|
||||
PluginExtensionPanelContext,
|
||||
PluginExtensionPoints,
|
||||
PluginExtensionTypes,
|
||||
urlUtil,
|
||||
} from '@grafana/data';
|
||||
import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { LocalValueVariable, sceneGraph, SceneGridRow, VizPanel, VizPanelMenu } from '@grafana/scenes';
|
||||
import { DataQuery, OptionsWithLegend } from '@grafana/schema';
|
||||
import appEvents from 'app/core/app_events';
|
||||
@@ -22,6 +23,9 @@ import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils
|
||||
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
|
||||
import { InspectTab } from 'app/features/inspector/types';
|
||||
import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
|
||||
import { createPluginExtensionsGetter } from 'app/features/plugins/extensions/getPluginExtensions';
|
||||
import { pluginExtensionRegistries } from 'app/features/plugins/extensions/registry/setup';
|
||||
import { GetPluginExtensions } from 'app/features/plugins/extensions/types';
|
||||
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
|
||||
import { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration';
|
||||
import { dispatch } from 'app/store/store';
|
||||
@@ -39,6 +43,18 @@ import { DashboardScene } from './DashboardScene';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
|
||||
import { UnlinkLibraryPanelModal } from './UnlinkLibraryPanelModal';
|
||||
|
||||
let getPluginExtensions: GetPluginExtensions;
|
||||
|
||||
function setupGetPluginExtensions() {
|
||||
if (getPluginExtensions) {
|
||||
return getPluginExtensions;
|
||||
}
|
||||
|
||||
getPluginExtensions = createPluginExtensionsGetter(pluginExtensionRegistries);
|
||||
|
||||
return getPluginExtensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior is called when VizPanelMenu is activated (ie when it's opened).
|
||||
*/
|
||||
@@ -286,20 +302,22 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
||||
|
||||
items.push(getInspectMenuItem(plugin, panel, dashboard));
|
||||
|
||||
// TODO: make sure that this works reliably with the reactive extension registry
|
||||
// (we need to be able to know in advance what extensions should be loaded for this extension point, and make it possible to await for them.)
|
||||
const { extensions } = getPluginLinkExtensions({
|
||||
setupGetPluginExtensions();
|
||||
|
||||
const { extensions } = getPluginExtensions({
|
||||
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
||||
context: createExtensionContext(panel, dashboard),
|
||||
limitPerPlugin: 3,
|
||||
});
|
||||
|
||||
const linkExtensions = extensions.filter((extension) => extension.type === PluginExtensionTypes.link);
|
||||
|
||||
if (extensions.length > 0 && !dashboard.state.isEditing) {
|
||||
items.push({
|
||||
text: 'Extensions',
|
||||
iconClassName: 'plug',
|
||||
type: 'submenu',
|
||||
subMenu: createExtensionSubMenu(extensions),
|
||||
subMenu: createExtensionSubMenu(linkExtensions),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
VariableSupportType,
|
||||
} from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime';
|
||||
import { setPluginImportUtils } from '@grafana/runtime';
|
||||
import { MultiValueVariable, sceneGraph, SceneGridRow, VizPanel } from '@grafana/scenes';
|
||||
import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
@@ -154,8 +154,6 @@ jest.mock('@grafana/data', () => ({
|
||||
setWeekStart: jest.fn(),
|
||||
}));
|
||||
|
||||
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
|
||||
|
||||
jest.mock('@grafana/scenes', () => ({
|
||||
...jest.requireActual('@grafana/scenes'),
|
||||
sceneUtils: {
|
||||
@@ -165,11 +163,6 @@ jest.mock('@grafana/scenes', () => ({
|
||||
}));
|
||||
|
||||
describe('transformSceneToSaveModel', () => {
|
||||
beforeEach(() => {
|
||||
getPluginLinkExtensionsMock.mockRestore();
|
||||
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||
});
|
||||
|
||||
describe('Given a simple scene with custom settings', () => {
|
||||
it('Should transform back to persisted model', () => {
|
||||
const dashboardWithCustomSettings = {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
}
|
||||
@@ -4,10 +4,7 @@ import * as React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import {
|
||||
type PluginExtensionLinkConfig,
|
||||
type PluginExtensionConfig,
|
||||
type PluginExtensionEventHelpers,
|
||||
PluginExtensionTypes,
|
||||
type PluginExtensionOpenModalOptions,
|
||||
isDateTime,
|
||||
dateTime,
|
||||
@@ -29,12 +26,6 @@ import { ExtensionsLog, log } from './logs/log';
|
||||
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
|
||||
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
|
||||
|
||||
export function isPluginExtensionLinkConfig(
|
||||
extension: PluginExtensionConfig | undefined
|
||||
): extension is PluginExtensionLinkConfig {
|
||||
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.link;
|
||||
}
|
||||
|
||||
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
|
||||
return (...args: unknown[]) => {
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { memo } from 'react';
|
||||
import {
|
||||
PluginContextType,
|
||||
PluginExtensionAddedLinkConfig,
|
||||
PluginExtensionLinkConfig,
|
||||
PluginExtensionPoints,
|
||||
PluginLoadingStrategy,
|
||||
PluginType,
|
||||
@@ -92,16 +91,13 @@ describe('Plugin Extension Validators', () => {
|
||||
|
||||
it('should throw an error if the configure() function is defined but is not a function', () => {
|
||||
expect(() => {
|
||||
assertConfigureIsValid(
|
||||
// @ts-ignore
|
||||
{
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
extensionPointId: 'grafana/some-page/extension-point-a',
|
||||
handler: () => {},
|
||||
configure: '() => {}',
|
||||
} as PluginExtensionLinkConfig
|
||||
);
|
||||
assertConfigureIsValid({
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
extensionPointId: 'grafana/some-page/extension-point-a',
|
||||
handler: () => {},
|
||||
configure: '() => {}',
|
||||
} as unknown as PluginExtensionAddedLinkConfig); // We are casting to unknown to test it with a unvalid argument
|
||||
}).toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isFunction } from 'lodash';
|
||||
import { ComponentType, FC } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaPlugin, PluginExtensionConfig, PluginType } from '@grafana/data';
|
||||
import { GrafanaPlugin, PluginType } from '@grafana/data';
|
||||
|
||||
import { SandboxPluginMeta, SandboxedPluginObject } from './types';
|
||||
import { isSandboxedPluginObject } from './utils';
|
||||
@@ -58,17 +58,6 @@ export async function sandboxPluginComponents(
|
||||
Reflect.set(pluginObject, 'root', withSandboxWrapper(Reflect.get(pluginObject, 'root'), meta));
|
||||
}
|
||||
|
||||
// extension components
|
||||
if (Reflect.has(pluginObject, 'extensionConfigs')) {
|
||||
const extensions: PluginExtensionConfig[] = Reflect.get(pluginObject, 'extensionConfigs');
|
||||
for (const extension of extensions) {
|
||||
if (Reflect.has(extension, 'component')) {
|
||||
Reflect.set(extension, 'component', withSandboxWrapper(Reflect.get(extension, 'component'), meta));
|
||||
}
|
||||
}
|
||||
Reflect.set(pluginObject, 'extensionConfigs', extensions);
|
||||
}
|
||||
|
||||
// config pages
|
||||
if (Reflect.has(pluginObject, 'configPages')) {
|
||||
const configPages: NonNullable<GrafanaPlugin['configPages']> = Reflect.get(pluginObject, 'configPages') ?? [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { getPluginExtensions, isPluginExtensionLink } from '@grafana/runtime';
|
||||
import { usePluginLinks } from '@grafana/runtime';
|
||||
import { Button, LinkButton, Stack, Text } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
@@ -46,21 +46,18 @@ export const TestStuffPage = () => {
|
||||
};
|
||||
|
||||
function LinkToBasicApp({ extensionPointId }: { extensionPointId: string }) {
|
||||
const { extensions } = getPluginExtensions({ extensionPointId });
|
||||
const { links } = usePluginLinks({ extensionPointId });
|
||||
|
||||
if (extensions.length === 0) {
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{extensions.map((extension, i) => {
|
||||
if (!isPluginExtensionLink(extension)) {
|
||||
return null;
|
||||
}
|
||||
{links.map((link, i) => {
|
||||
return (
|
||||
<LinkButton href={extension.path} title={extension.description} key={extension.id}>
|
||||
{extension.title}
|
||||
<LinkButton href={link.path} title={link.description} key={link.id}>
|
||||
{link.title}
|
||||
</LinkButton>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataFrame, PanelMenuItem } from '@grafana/data';
|
||||
import { getPluginLinkExtensions } from '@grafana/runtime';
|
||||
import { isPluginExtensionLink } from '@grafana/runtime';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
sceneGraph,
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
} from '@grafana/scenes';
|
||||
import { getExploreUrl } from 'app/core/utils/explore';
|
||||
import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils';
|
||||
import { createPluginExtensionsGetter } from 'app/features/plugins/extensions/getPluginExtensions';
|
||||
import { pluginExtensionRegistries } from 'app/features/plugins/extensions/registry/setup';
|
||||
import { type GetPluginExtensions } from 'app/features/plugins/extensions/types';
|
||||
|
||||
import { AddToExplorationButton, extensionPointId } from '../MetricSelect/AddToExplorationsButton';
|
||||
import { getDataSource, getTrailFor } from '../utils';
|
||||
@@ -28,6 +31,18 @@ interface PanelMenuState extends SceneObjectState {
|
||||
explorationsButton?: AddToExplorationButton;
|
||||
}
|
||||
|
||||
let getPluginExtensions: GetPluginExtensions;
|
||||
|
||||
function setupGetPluginExtensions() {
|
||||
if (getPluginExtensions) {
|
||||
return getPluginExtensions;
|
||||
}
|
||||
|
||||
getPluginExtensions = createPluginExtensionsGetter(pluginExtensionRegistries);
|
||||
|
||||
return getPluginExtensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo the VizPanelMenu interface is overly restrictive, doesn't allow any member functions on this class, so everything is currently inlined
|
||||
*/
|
||||
@@ -93,6 +108,8 @@ export class PanelMenu extends SceneObjectBase<PanelMenuState> implements VizPan
|
||||
this.state.explorationsButton?.activate();
|
||||
}
|
||||
});
|
||||
|
||||
setupGetPluginExtensions();
|
||||
}
|
||||
|
||||
addItem(item: PanelMenuItem): void {
|
||||
@@ -119,12 +136,12 @@ export class PanelMenu extends SceneObjectBase<PanelMenuState> implements VizPan
|
||||
}
|
||||
|
||||
const getInvestigationLink = (addToExplorations: AddToExplorationButton) => {
|
||||
const links = getPluginLinkExtensions({
|
||||
extensionPointId: extensionPointId,
|
||||
const links = getPluginExtensions({
|
||||
extensionPointId,
|
||||
context: addToExplorations.state.context,
|
||||
});
|
||||
}).extensions.filter((ext) => isPluginExtensionLink(ext));
|
||||
|
||||
return links.extensions[0];
|
||||
return links[0];
|
||||
};
|
||||
|
||||
const onAddToInvestigationClick = (event: React.MouseEvent, addToExplorations: AddToExplorationButton) => {
|
||||
|
||||
-2
@@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CoreApp, PluginType } from '@grafana/data';
|
||||
import { setPluginExtensionsHook } from '@grafana/runtime';
|
||||
|
||||
import { PyroscopeDataSource } from '../datasource';
|
||||
import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test';
|
||||
@@ -12,7 +11,6 @@ import { Props, QueryEditor } from './QueryEditor';
|
||||
|
||||
describe('QueryEditor', () => {
|
||||
beforeEach(() => {
|
||||
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
|
||||
mockFetchPyroscopeDatasourceSettings();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DataSourceJsonData,
|
||||
makeTimeRange,
|
||||
} from '@grafana/data';
|
||||
import { setPluginExtensionsHook, getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime';
|
||||
import { getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime';
|
||||
|
||||
import { defaultPyroscopeQueryType } from './dataquery.gen';
|
||||
import { normalizeQuery, PyroscopeDataSource } from './datasource';
|
||||
@@ -35,7 +35,6 @@ export function mockFetchPyroscopeDatasourceSettings(
|
||||
|
||||
function setupDatasource() {
|
||||
mockFetchPyroscopeDatasourceSettings();
|
||||
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
|
||||
const templateSrv = {
|
||||
replace: (query: string): string => {
|
||||
return query.replace(/\$var/g, 'interpolated');
|
||||
|
||||
Reference in New Issue
Block a user