Datasources: Make the datasources config extendable by plugins (#68064)

* feat: add a new UI extension type: component

* fix: remove reference to not existing type

* chore: update betterer results

* review: use a single type notation in import

* review: stop exporting `PluginExtensionBase`

* refactor: make extension config types more explicit

By using some repetition now these types are much easier to oversee.

* feat: add a new extension point to the datasources config

* fix: export tcontext type from grafana-data

* chore: update betterer results

* chore: fix tests

* feat: extend the context shared with extensions

* feat: stop omitting jsonData props & update context type

* tests: update tests
This commit is contained in:
Levente Balogh
2023-06-02 11:01:36 +02:00
committed by GitHub
parent 31ecbf7062
commit d25cea90b0
7 changed files with 168 additions and 8 deletions

View File

@@ -2,8 +2,8 @@ import { screen, render } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { PluginState } from '@grafana/data';
import { setAngularLoader } from '@grafana/runtime';
import { PluginExtensionTypes, PluginState } from '@grafana/data';
import { setAngularLoader, setPluginExtensionGetter } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__';
@@ -13,9 +13,8 @@ import { readOnlyMessage } from './DataSourceReadOnlyMessage';
import { EditDataSourceView, ViewProps } from './EditDataSource';
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
return {
...original,
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: (uid: string) => ({
uid,
@@ -59,6 +58,10 @@ describe('<EditDataSource>', () => {
});
});
beforeEach(() => {
setPluginExtensionGetter(jest.fn().mockReturnValue({ extensions: [] }));
});
describe('On loading errors', () => {
it('should render a Back button', () => {
setup({
@@ -261,4 +264,101 @@ describe('<EditDataSource>', () => {
expect(screen.queryByText(detailsVerboseMessage)).toBeInTheDocument();
});
});
describe('when extending the datasource config form', () => {
it('should be possible to extend the form with a "component" extension in case the plugin ID is whitelisted', () => {
const message = "I'm a UI extension component!";
setPluginExtensionGetter(
jest.fn().mockReturnValue({
extensions: [
{
id: '1',
pluginId: 'grafana-pdc-app',
type: PluginExtensionTypes.component,
title: 'Example component',
description: 'Example description',
component: () => <div>{message}</div>,
},
],
})
);
setup({
dataSourceRights: {
readOnly: false,
hasDeleteRights: true,
hasWriteRights: true,
},
});
expect(screen.queryByText(message)).toBeVisible();
});
it('should NOT be possible to extend the form with a "component" extension in case the plugin ID is NOT whitelisted', () => {
const message = "I'm a UI extension component!";
setPluginExtensionGetter(
jest.fn().mockReturnValue({
extensions: [
{
id: '1',
pluginId: 'myorg-basic-app',
type: PluginExtensionTypes.component,
title: 'Example component',
description: 'Example description',
component: () => <div>{message}</div>,
},
],
})
);
setup({
dataSourceRights: {
readOnly: false,
hasDeleteRights: true,
hasWriteRights: true,
},
});
expect(screen.queryByText(message)).not.toBeInTheDocument();
});
it('should pass a context prop to the rendered UI extension component', () => {
const message = "I'm a UI extension component!";
const component = jest.fn().mockReturnValue(<div>{message}</div>);
setPluginExtensionGetter(
jest.fn().mockReturnValue({
extensions: [
{
id: '1',
pluginId: 'grafana-pdc-app',
type: PluginExtensionTypes.component,
title: 'Example component',
description: 'Example description',
component,
},
],
})
);
setup({
dataSourceRights: {
readOnly: false,
hasDeleteRights: true,
hasWriteRights: true,
},
});
expect(component).toHaveBeenCalled();
const props = component.mock.calls[0][0];
expect(props.context).toBeDefined();
expect(props.context.dataSource).toBeDefined();
expect(props.context.dataSourceMeta).toBeDefined();
expect(props.context.setJsonData).toBeDefined();
});
});
});

View File

@@ -1,12 +1,18 @@
import { AnyAction } from '@reduxjs/toolkit';
import React from 'react';
import { omit } from 'lodash';
import React, { useMemo } from 'react';
import {
DataSourcePluginContextProvider,
DataSourcePluginMeta,
DataSourceSettings as DataSourceSettingsType,
PluginExtensionPoints,
PluginExtensionDataSourceConfigContext,
DataSourceJsonData,
DataSourceUpdatedSuccessfully,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { getDataSourceSrv, getPluginComponentExtensions } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { DataSourceSettingsState, useDispatch } from 'app/types';
@@ -125,6 +131,7 @@ export function EditDataSourceView({
try {
await onUpdate({ ...dataSource });
trackDsConfigUpdated('success');
appEvents.publish(new DataSourceUpdatedSuccessfully());
} catch (err) {
trackDsConfigUpdated('fail');
return;
@@ -133,6 +140,14 @@ export function EditDataSourceView({
onTest();
};
const extensions = useMemo(() => {
const allowedPluginIds = ['grafana-pdc-app', 'grafana-auth-app'];
const extensionPointId = PluginExtensionPoints.DataSourceConfig;
const { extensions } = getPluginComponentExtensions({ extensionPointId });
return extensions.filter((e) => allowedPluginIds.includes(e.pluginId));
}, []);
if (loadError) {
return (
<DataSourceLoadError
@@ -190,6 +205,29 @@ export function EditDataSourceView({
</DataSourcePluginContextProvider>
)}
{/* Extension point */}
{extensions.map((extension) => {
const Component = extension.component as React.ComponentType<{
context: PluginExtensionDataSourceConfigContext<DataSourceJsonData>;
}>;
return (
<div key={extension.id}>
<Component
context={{
dataSource: omit(dataSource, ['secureJsonData']),
dataSourceMeta: dataSourceMeta,
setJsonData: (jsonData) =>
onOptionsChange({
...dataSource,
jsonData: { ...dataSource.jsonData, ...jsonData },
}),
}}
/>
</div>
);
})}
<DataSourceTestingStatus testingStatus={testingStatus} exploreUrl={exploreUrl} dataSource={dataSource} />
<ButtonRow

View File

@@ -4,7 +4,7 @@ import { Store } from 'redux';
import { TestProvider } from 'test/helpers/TestProvider';
import { LayoutModes } from '@grafana/data';
import { setAngularLoader, config } from '@grafana/runtime';
import { setAngularLoader, config, setPluginExtensionGetter } from '@grafana/runtime';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { configureStore } from 'app/store/configureStore';
@@ -71,6 +71,7 @@ describe('<EditDataSourcePage>', () => {
beforeEach(() => {
// @ts-ignore
api.getDataSourceByIdOrUid = jest.fn().mockResolvedValue(dataSource);
setPluginExtensionGetter(jest.fn().mockReturnValue({ extensions: [] }));
store = configureStore({
dataSourceSettings,