From d25cea90b0c64601467aa0a483b1df041f605e87 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Fri, 2 Jun 2023 11:01:36 +0200 Subject: [PATCH] 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 --- .betterer.results | 3 + packages/grafana-data/src/events/common.ts | 4 + packages/grafana-data/src/types/index.ts | 1 + .../src/types/pluginExtensions.ts | 15 ++- .../components/EditDataSource.test.tsx | 108 +++++++++++++++++- .../datasources/components/EditDataSource.tsx | 42 ++++++- .../pages/EditDataSourcePage.test.tsx | 3 +- 7 files changed, 168 insertions(+), 8 deletions(-) diff --git a/.betterer.results b/.betterer.results index 7209b7e42f9..6b120495549 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2499,6 +2499,9 @@ exports[`better eslint`] = { "public/app/features/datasources/components/DataSourceTypeCard.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] ], + "public/app/features/datasources/components/EditDataSource.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/datasources/state/actions.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/packages/grafana-data/src/events/common.ts b/packages/grafana-data/src/events/common.ts index f66cdbd3c13..4b9ded63bc6 100644 --- a/packages/grafana-data/src/events/common.ts +++ b/packages/grafana-data/src/events/common.ts @@ -54,3 +54,7 @@ export type DashboardLoadedEventPayload = { export class DashboardLoadedEvent extends BusEventWithPayload> { static type = 'dashboard-loaded'; } + +export class DataSourceUpdatedSuccessfully extends BusEventBase { + static type = 'datasource-updated-successfully'; +} diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 35a8cb81e3b..6183d52d61a 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -62,4 +62,5 @@ export { type PluginExtensionComponentConfig, type PluginExtensionEventHelpers, type PluginExtensionPanelContext, + type PluginExtensionDataSourceConfigContext, } from './pluginExtensions'; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 68c90a233b4..33029172501 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -1,8 +1,9 @@ import React from 'react'; -import { DataQuery } from '@grafana/schema'; +import { DataQuery, DataSourceJsonData } from '@grafana/schema'; import { ScopedVars } from './ScopedVars'; +import { DataSourcePluginMeta, DataSourceSettings } from './datasource'; import { PanelData } from './panel'; import { RawTimeRange, TimeZone } from './time'; @@ -113,6 +114,18 @@ export type PluginExtensionPanelContext = { data?: PanelData; }; +export type PluginExtensionDataSourceConfigContext = { + // The current datasource settings + dataSource: DataSourceSettings; + + // Meta information about the datasource plugin + dataSourceMeta: DataSourcePluginMeta; + + // Can be used to update the `jsonData` field on the datasource + // (Only updates the form, it still needs to be saved by the user) + setJsonData: (jsonData: JsonData) => void; +}; + type Dashboard = { uid: string; title: string; diff --git a/public/app/features/datasources/components/EditDataSource.test.tsx b/public/app/features/datasources/components/EditDataSource.test.tsx index 166e836a667..1c55294da8c 100644 --- a/public/app/features/datasources/components/EditDataSource.test.tsx +++ b/public/app/features/datasources/components/EditDataSource.test.tsx @@ -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('', () => { }); }); + beforeEach(() => { + setPluginExtensionGetter(jest.fn().mockReturnValue({ extensions: [] })); + }); + describe('On loading errors', () => { it('should render a Back button', () => { setup({ @@ -261,4 +264,101 @@ describe('', () => { 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: () =>
{message}
, + }, + ], + }) + ); + + 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: () =>
{message}
, + }, + ], + }) + ); + + 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(
{message}
); + + 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(); + }); + }); }); diff --git a/public/app/features/datasources/components/EditDataSource.tsx b/public/app/features/datasources/components/EditDataSource.tsx index b969b9d7c6a..e1ed826edcd 100644 --- a/public/app/features/datasources/components/EditDataSource.tsx +++ b/public/app/features/datasources/components/EditDataSource.tsx @@ -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 ( )} + {/* Extension point */} + {extensions.map((extension) => { + const Component = extension.component as React.ComponentType<{ + context: PluginExtensionDataSourceConfigContext; + }>; + + return ( +
+ + onOptionsChange({ + ...dataSource, + jsonData: { ...dataSource.jsonData, ...jsonData }, + }), + }} + /> +
+ ); + })} + ', () => { beforeEach(() => { // @ts-ignore api.getDataSourceByIdOrUid = jest.fn().mockResolvedValue(dataSource); + setPluginExtensionGetter(jest.fn().mockReturnValue({ extensions: [] })); store = configureStore({ dataSourceSettings,