diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx index 07e4c3de2b4..75c0f98e564 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx @@ -3,13 +3,20 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { CoreApp, PluginType } from '@grafana/data'; +import { setPluginExtensionGetter } from '@grafana/runtime'; import { PyroscopeDataSource } from '../datasource'; +import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test'; import { ProfileTypeMessage } from '../types'; import { Props, QueryEditor } from './QueryEditor'; describe('QueryEditor', () => { + beforeEach(() => { + setPluginExtensionGetter(() => ({ extensions: [] })); // No extensions + mockFetchPyroscopeDatasourceSettings(); + }); + it('should render without error', async () => { setup(); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx index be3ca6eef6e..344d12820b1 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.tsx @@ -12,6 +12,7 @@ import { EditorRow } from './EditorRow'; import { EditorRows } from './EditorRows'; import { LabelsEditor } from './LabelsEditor'; import { ProfileTypesCascader, useProfileTypes } from './ProfileTypesCascader'; +import { PyroscopeQueryLinkExtensions } from './QueryLinkExtension'; import { QueryOptions } from './QueryOptions'; export type Props = QueryEditorProps; @@ -57,6 +58,7 @@ export function QueryEditor(props: Props) { labels={labels} getLabelValues={getLabelValues} /> + diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.test.tsx new file mode 100644 index 00000000000..28bfef9ecf6 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { PluginType, rangeUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; +import { getPluginLinkExtensions } from '@grafana/runtime'; + +import { PyroscopeDataSource } from '../datasource'; +import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test'; + +import { Props, PyroscopeQueryLinkExtensions, resetPyroscopeQueryLinkExtensionsFetches } from './QueryLinkExtension'; + +// Constants copied from `QueryLinkExtension.tsx` +const EXTENSION_POINT_ID = 'plugins/grafana-pyroscope-datasource/query-links'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + setPluginExtensionGetter: jest.fn(), + getPluginLinkExtensions: jest.fn(), +})); + +const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); + +const defaultPyroscopeDataSourceSettings = { + uid: 'default-pyroscope', + url: 'http://pyroscope', + basicAuthUser: 'pyroscope_user', +}; + +describe('PyroscopeQueryLinkExtensions', () => { + const EXPECTED_BUTTON_LABEL = 'Profiles App'; + const DEFAULT_EXTENSION_PATH = 'a/mock-path-app/fake-path'; + + function createExtension(overrides?: Partial) { + return { + ...{ + description: 'unremarkable-description', + extensionPointId: EXTENSION_POINT_ID, + title: EXPECTED_BUTTON_LABEL, + path: DEFAULT_EXTENSION_PATH, + type: PluginExtensionTypes.link, + category: 'unremarkable-category', + icon: 'heart', + onClick() {}, + pluginId: 'mock-path-app', + id: `${Date.now()}}`, + }, + ...overrides, + } as PluginExtensionLink; + } + + beforeEach(() => { + resetPyroscopeQueryLinkExtensionsFetches(); + mockFetchPyroscopeDatasourceSettings(defaultPyroscopeDataSourceSettings); + + getPluginLinkExtensionsMock.mockRestore(); + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); // Unless stated otherwise, no extensions + }); + + it('should render if extension present', async () => { + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [createExtension()] }); // Default extension + + await act(setup); + expect(await screen.findAllByText(EXPECTED_BUTTON_LABEL)).toBeDefined(); + }); + + it('Should not render if no extension present', async () => { + await act(setup); + expect(screen.queryByText(EXPECTED_BUTTON_LABEL)).toBeNull(); + }); +}); + +function setupDs() { + const ds = new PyroscopeDataSource({ + ...defaultPyroscopeDataSourceSettings, + name: 'test', + type: PluginType.datasource, + access: 'proxy', + id: 1, + jsonData: {}, + meta: { + name: '', + id: '', + type: PluginType.datasource, + baseUrl: '', + info: { + author: { + name: '', + }, + description: '', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + module: '', + }, + readOnly: false, + }); + + return ds; +} + +async function setup(options: { props: Partial } = { props: {} }) { + const utils = render( + + ); + return { ...utils }; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.tsx new file mode 100644 index 00000000000..21a8a727ad5 --- /dev/null +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.tsx @@ -0,0 +1,104 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useAsync } from 'react-use'; + +import { GrafanaTheme2, QueryEditorProps, TimeRange } from '@grafana/data'; +import { getBackendSrv, getPluginLinkExtensions } from '@grafana/runtime'; +import { LinkButton, useStyles2 } from '@grafana/ui'; + +import { PyroscopeDataSource } from '../datasource'; +import { PyroscopeDataSourceOptions, Query } from '../types'; + +const EXTENSION_POINT_ID = 'plugins/grafana-pyroscope-datasource/query-links'; + +/** A subset of the datasource settings that are relevant for this integration */ +type PyroscopeDatasourceSettings = { + uid: string; + url: string; + type: string; + basicAuthUser: string; +}; + +/** The context object that will be shared with the link extension's configure function */ +type ExtensionQueryLinksContext = { + datasourceUid: string; + query: Query; + range?: TimeRange | undefined; + datasourceSettings?: PyroscopeDatasourceSettings; +}; + +/* Global promises to fetch pyroscope datasource settings by uid as encountered */ +const pyroscopeDatasourceSettingsByUid: Record = {}; + +/* Reset promises for testing purposes */ +export function resetPyroscopeQueryLinkExtensionsFetches() { + Object.keys(pyroscopeDatasourceSettingsByUid).forEach((key) => delete pyroscopeDatasourceSettingsByUid[key]); +} + +/** A subset of the `PyroscopeDataSource` `QueryEditorProps` */ +export type Props = Pick< + QueryEditorProps, + 'datasource' | 'query' | 'range' +>; + +export function PyroscopeQueryLinkExtensions(props: Props) { + const { + datasource: { uid: datasourceUid }, + query, + range, + } = props; + + const { value: datasourceSettings } = useAsync(async () => { + if (pyroscopeDatasourceSettingsByUid[datasourceUid]) { + return pyroscopeDatasourceSettingsByUid[datasourceUid]; + } + const settings = await getBackendSrv().get(`/api/datasources/uid/${datasourceUid}`); + pyroscopeDatasourceSettingsByUid[datasourceUid] = settings; + return settings; + }, [datasourceUid]); + + const context: ExtensionQueryLinksContext = { + datasourceUid, + query, + range, + datasourceSettings, + }; + + const { extensions } = getPluginLinkExtensions({ + extensionPointId: EXTENSION_POINT_ID, + context, + }); + + const styles = useStyles2(getStyles); + + if (extensions.length === 0) { + return null; + } + + return ( + <> + {extensions.map((extension) => ( + + {extension.title} + + ))} + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + linkButton: css({ + marginLeft: theme.spacing(1), + }), + }; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts index c9f09aaa38c..f2c296e31eb 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts @@ -1,13 +1,43 @@ -import { AbstractLabelOperator, CoreApp, DataSourceInstanceSettings, PluginMetaInfo, PluginType } from '@grafana/data'; +import { + AbstractLabelOperator, + CoreApp, + DataSourceInstanceSettings, + PluginMetaInfo, + PluginType, + DataSourceJsonData, +} from '@grafana/data'; +import { setPluginExtensionGetter, getBackendSrv, setBackendSrv } from '@grafana/runtime'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { defaultPyroscopeQueryType } from './dataquery.gen'; import { normalizeQuery, PyroscopeDataSource } from './datasource'; import { Query } from './types'; +/** The datasource QueryEditor fetches datasource settings to send to the extension's `configure` method */ +export function mockFetchPyroscopeDatasourceSettings( + datasourceSettings?: Partial> +) { + const settings = { ...defaultSettings, ...datasourceSettings }; + const returnValues: Record = { + [`/api/datasources/uid/${settings.uid}`]: settings, + }; + setBackendSrv({ + ...getBackendSrv(), + get: function (path: string) { + const value = returnValues[path]; + if (value) { + return Promise.resolve(value as T); + } + return Promise.reject({ message: 'reject' }); + }, + }); +} + describe('Pyroscope data source', () => { let ds: PyroscopeDataSource; beforeEach(() => { + mockFetchPyroscopeDatasourceSettings(); + setPluginExtensionGetter(() => ({ extensions: [] })); // No extensions ds = new PyroscopeDataSource(defaultSettings); });