From e948376a95a2f6d6993e1435dc9b639cae75a4f5 Mon Sep 17 00:00:00 2001 From: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Tue, 18 Mar 2025 12:45:01 -0400 Subject: [PATCH] feat: component extension point for adaptive telemetry query actions (#101331) * feat: component extension point for adaptive telemetry query actions - only render the first non-null added-component, and provide utility in the added component infrastructure to support this --------- Co-authored-by: Levente Balogh --- packages/grafana-data/src/index.ts | 1 + .../src/types/pluginExtensions.ts | 7 + .../grafana-runtime/src/services/index.ts | 7 +- .../services/pluginExtensions/utils.test.ts | 50 ---- .../services/pluginExtensions/utils.test.tsx | 269 ++++++++++++++++++ .../src/services/pluginExtensions/utils.ts | 22 -- .../src/services/pluginExtensions/utils.tsx | 103 +++++++ .../query/components/QueryEditorRow.tsx | 36 ++- 8 files changed, 421 insertions(+), 74 deletions(-) delete mode 100644 packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts create mode 100644 packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx delete mode 100644 packages/grafana-runtime/src/services/pluginExtensions/utils.ts create mode 100644 packages/grafana-runtime/src/services/pluginExtensions/utils.tsx diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 3ee46e3beb6..bddb29ba007 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -557,6 +557,7 @@ export { type PluginExtensionComponentConfig, type PluginExtensionEventHelpers, type PluginExtensionPanelContext, + type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context, type PluginExtensionDataSourceConfigContext, type PluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 318a66f145b..8f9887a705a 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -186,6 +186,7 @@ export enum PluginExtensionPoints { ExploreToolbarAction = 'grafana/explore/toolbar/action', UserProfileTab = 'grafana/user/profile/tab', TraceViewDetails = 'grafana/traceview/details', + QueryEditorRowAdaptiveTelemetryV1 = 'grafana/query-editor-row/adaptivetelemetry/v1', } export type PluginExtensionPanelContext = { @@ -200,6 +201,12 @@ export type PluginExtensionPanelContext = { data?: PanelData; }; +export type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context = { + /** An ordered list of lower-case [a-z]+ string identifiers to provide context clues of where this component is being embedded and how we might want to consider displaying it */ + contextHints?: string[]; + query?: DataQuery; +}; + export type PluginExtensionDataSourceConfigContext< JsonData extends DataSourceJsonData = DataSourceJsonData, SecureJsonData = {}, diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 3ceef02923a..bbfed10df4e 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -53,7 +53,12 @@ export { type UsePluginFunctionsResult, } from './pluginExtensions/usePluginFunctions'; -export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; +export { + isPluginExtensionLink, + isPluginExtensionComponent, + getLimitedComponentsToRender, + renderLimitedComponents, +} from './pluginExtensions/utils'; export { setCurrentUser } from './user'; export { RuntimeDataSource } from './RuntimeDataSource'; export { ScopesContext, type ScopesContextValueState, type ScopesContextValue, useScopes } from './ScopesContext'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts deleted file mode 100644 index c4c6e1f10bd..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { PluginExtension, PluginExtensionTypes } from '@grafana/data'; - -import { isPluginExtensionLink } from './utils'; - -describe('Plugin Extensions / Utils', () => { - describe('isPluginExtensionLink()', () => { - test('should return TRUE if the object is a link extension', () => { - expect( - isPluginExtensionLink({ - id: 'id', - pluginId: 'plugin-id', - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - path: '...', - } as PluginExtension) - ).toBe(true); - - expect( - isPluginExtensionLink({ - id: 'id', - pluginId: 'plugin-id', - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - onClick: () => {}, - } as PluginExtension) - ).toBe(true); - }); - test('should return FALSE if the object is NOT a link extension', () => { - expect( - isPluginExtensionLink({ - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - } as PluginExtension) - ).toBe(false); - - expect( - // @ts-ignore (Right now we only have a single type of extension) - isPluginExtensionLink({ - type: 'unknown', - title: 'Title', - description: 'Description', - path: '...', - } as PluginExtension) - ).toBe(false); - }); - }); -}); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx new file mode 100644 index 00000000000..e235aace668 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx @@ -0,0 +1,269 @@ +import { render } from '@testing-library/react'; +import React from 'react'; + +import { + ComponentTypeWithExtensionMeta, + PluginExtension, + PluginExtensionComponentMeta, + PluginExtensionTypes, +} from '@grafana/data'; + +import { getLimitedComponentsToRender, isPluginExtensionLink, renderLimitedComponents } from './utils'; + +describe('Plugin Extensions / Utils', () => { + describe('isPluginExtensionLink()', () => { + test('should return TRUE if the object is a link extension', () => { + expect( + isPluginExtensionLink({ + id: 'id', + pluginId: 'plugin-id', + type: PluginExtensionTypes.link, + title: 'Title', + description: 'Description', + path: '...', + } as PluginExtension) + ).toBe(true); + + expect( + isPluginExtensionLink({ + id: 'id', + pluginId: 'plugin-id', + type: PluginExtensionTypes.link, + title: 'Title', + description: 'Description', + onClick: () => {}, + } as PluginExtension) + ).toBe(true); + }); + test('should return FALSE if the object is NOT a link extension', () => { + expect( + isPluginExtensionLink({ + type: PluginExtensionTypes.link, + title: 'Title', + description: 'Description', + } as PluginExtension) + ).toBe(false); + + expect( + // @ts-ignore (Right now we only have a single type of extension) + isPluginExtensionLink({ + type: 'unknown', + title: 'Title', + description: 'Description', + path: '...', + } as PluginExtension) + ).toBe(false); + }); + }); + + describe('getLimitedComponentsToRender()', () => { + test('should return `null` if it receives an empty array of components', () => { + const props = {}; + const components: Array> = []; + const limitedComponents = getLimitedComponentsToRender({ props, components }); + expect(limitedComponents).toEqual(null); + }); + + test('should return all components if no limit is provided', () => { + const props = {}; + const components: Array> = [ + createComponent(() =>
Test 1
, undefined, 'id-1'), + createComponent(() =>
Test 2
, undefined, 'id-2'), + createComponent(() =>
Test 3
, undefined, 'id-3'), + ]; + + expect(getLimitedComponentsToRender({ props, components })?.length).toEqual(3); + }); + + test('should limit the number of components', () => { + const props = {}; + const components: Array> = [ + createComponent(() =>
Test 1
, undefined, 'id-1'), + createComponent(() =>
Test 2
, undefined, 'id-2'), + createComponent(() =>
Test 3
, undefined, 'id-3'), + createComponent(() =>
Test 4
, undefined, 'id-4'), + createComponent(() =>
Test 5
, undefined, 'id-5'), + ]; + + // Check if the limit is respected + expect(getLimitedComponentsToRender({ props, components, limit: 1 })?.length).toEqual(1); + expect(getLimitedComponentsToRender({ props, components, limit: 3 })?.length).toEqual(3); + + // Check if the right components are selected + const limitedComponents = getLimitedComponentsToRender({ props, components, limit: 3 }); + const rendered = render( + <>{limitedComponents?.map((Component, index) => )} + ); + + expect(rendered.getByText('Test 1')).toBeInTheDocument(); + expect(rendered.getByText('Test 2')).toBeInTheDocument(); + expect(rendered.getByText('Test 3')).toBeInTheDocument(); + expect(rendered.queryByText('Test 4')).not.toBeInTheDocument(); + expect(rendered.queryByText('Test 5')).not.toBeInTheDocument(); + }); + + test('should work when using class components', () => { + const props = {}; + const Component1 = class extends React.Component<{}> { + render() { + return
Test 1
; + } + }; + const Component2 = class extends React.Component<{}> { + render() { + return
Test 2
; + } + }; + + const components: Array> = [ + createComponent(Component1, undefined, 'id-1'), + createComponent(Component2, undefined, 'id-2'), + ]; + + // Check if the limit is respected + expect(getLimitedComponentsToRender({ props, components, limit: 1 })?.length).toEqual(1); + expect(getLimitedComponentsToRender({ props, components, limit: 2 })?.length).toEqual(2); + + // Check if the right components are selected + const limitedComponents = getLimitedComponentsToRender({ props, components, limit: 1 }); + const rendered = render( + <>{limitedComponents?.map((Component, index) => )} + ); + + expect(rendered.getByText('Test 1')).toBeInTheDocument(); + expect(rendered.queryByText('Test 2')).not.toBeInTheDocument(); + }); + + test('should filter components by plugin id', () => { + const props = {}; + const components: Array> = [ + createComponent(() =>
Test 1
, 'plugin-id-1', 'id-1'), + createComponent(() =>
Test 2
, 'plugin-id-2', 'id-2'), + createComponent(() =>
Test 3
, 'plugin-id-3', 'id-3'), + createComponent(() =>
Test 4
, 'plugin-id-4', 'id-4'), + createComponent(() =>
Test 5
, 'plugin-id-5', 'id-5'), + ]; + + // Check if the filtering works + expect(getLimitedComponentsToRender({ props, components, pluginId: 'plugin-id-1' })?.length).toEqual(1); + expect( + getLimitedComponentsToRender({ props, components, pluginId: ['plugin-id-1', 'plugin-id-2'] })?.length + ).toEqual(2); + expect(getLimitedComponentsToRender({ props, components, pluginId: /plugin-id.*/ })?.length).toEqual(5); + + // Check if the right components are selected + const limitedComponents = getLimitedComponentsToRender({ + props, + components, + pluginId: ['plugin-id-2', 'plugin-id-3'], + }); + const rendered = render( + <>{limitedComponents?.map((Component, index) => )} + ); + + expect(rendered.getByText('Test 2')).toBeInTheDocument(); + expect(rendered.getByText('Test 3')).toBeInTheDocument(); + expect(rendered.queryByText('Test 1')).not.toBeInTheDocument(); + expect(rendered.queryByText('Test 4')).not.toBeInTheDocument(); + expect(rendered.queryByText('Test 5')).not.toBeInTheDocument(); + }); + + test('should filter components based on both limit and plugin id', () => { + const props = {}; + const components: Array> = [ + createComponent(() =>
Test 1
, 'plugin-id-1', 'id-1'), + createComponent(() =>
Test 2
, 'plugin-id-2', 'id-2'), + createComponent(() =>
Test 3
, 'plugin-id-3', 'id-3'), + createComponent(() =>
Test 4
, 'plugin-id-4', 'id-4'), + createComponent(() =>
Test 5
, 'plugin-id-5', 'id-5'), + ]; + + // Check if the filtering works + expect(getLimitedComponentsToRender({ props, components, limit: 1, pluginId: /plugin-id.*/ })?.length).toEqual(1); + expect(getLimitedComponentsToRender({ props, components, limit: 2, pluginId: 'plugin-id-3' })?.length).toEqual(1); + expect( + getLimitedComponentsToRender({ + props, + components, + limit: 1, + pluginId: ['plugin-id-1', 'plugin-id-2', 'plugin-id-3'], + })?.length + ).toEqual(1); + }); + }); + + describe('renderLimitedComponents()', () => { + test('should render all components if no limit is provided', () => { + const props = {}; + const components: Array> = [ + createComponent(() =>
Test 1
, 'plugin-id-1', 'id-1'), + createComponent(() =>
Test 2
, 'plugin-id-1', 'id-2'), + createComponent(() =>
Test 3
, 'plugin-id-2', 'id-3'), + createComponent(() =>
Test 4
, 'plugin-id-3', 'id-4'), + ]; + + const rendered = render(<>{renderLimitedComponents({ props, components })}); + + expect(rendered.getByText('Test 1')).toBeInTheDocument(); + expect(rendered.getByText('Test 2')).toBeInTheDocument(); + expect(rendered.getByText('Test 3')).toBeInTheDocument(); + expect(rendered.getByText('Test 4')).toBeInTheDocument(); + }); + + test('should limit the number of components', () => { + const props = {}; + const components: Array> = [ + createComponent(() =>
Test 1
, 'plugin-id-1', 'id-1'), + createComponent(() =>
Test 2
, 'plugin-id-2', 'id-2'), + createComponent(() =>
Test 3
, 'plugin-id-3', 'id-3'), + ]; + + const rendered = render(<>{renderLimitedComponents({ props, components, limit: 1 })}); + + expect(rendered.getByText('Test 1')).toBeInTheDocument(); + expect(rendered.queryByText('Test 2')).not.toBeInTheDocument(); + expect(rendered.queryByText('Test 3')).not.toBeInTheDocument(); + }); + + test('should filter components by plugin id', () => { + const props = {}; + const components: Array> = [ + createComponent(() =>
Test 1
, 'plugin-id-1', 'id-1'), + createComponent(() =>
Test 2
, 'plugin-id-2', 'id-2'), + createComponent(() =>
Test 3
, 'plugin-id-3', 'id-3'), + ]; + + const rendered = render(<>{renderLimitedComponents({ props, components, pluginId: ['plugin-id-2'] })}); + expect(rendered.getByText('Test 2')).toBeInTheDocument(); + expect(rendered.queryByText('Test 1')).not.toBeInTheDocument(); + expect(rendered.queryByText('Test 3')).not.toBeInTheDocument(); + }); + }); +}); + +function createComponent( + Implementation?: React.ComponentType, + pluginId?: string, + id?: string +): ComponentTypeWithExtensionMeta { + function ComponentWithMeta(props: Props) { + if (Implementation) { + return ; + } + + return
Test
; + } + + ComponentWithMeta.displayName = ''; + ComponentWithMeta.propTypes = {}; + ComponentWithMeta.contextTypes = {}; + ComponentWithMeta.meta = { + id: id ?? '', + pluginId: pluginId ?? '', + title: '', + description: '', + type: PluginExtensionTypes.component, + } satisfies PluginExtensionComponentMeta; + + return ComponentWithMeta; +} diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.ts deleted file mode 100644 index afd08da9e49..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - type PluginExtension, - type PluginExtensionComponent, - type PluginExtensionLink, - PluginExtensionTypes, -} from '@grafana/data'; - -export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink { - if (!extension) { - return false; - } - return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension); -} - -export function isPluginExtensionComponent( - extension: PluginExtension | undefined -): extension is PluginExtensionComponent { - if (!extension) { - return false; - } - return extension.type === PluginExtensionTypes.component && 'component' in extension; -} diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx b/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx new file mode 100644 index 00000000000..3bb27160ef5 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import { + ComponentTypeWithExtensionMeta, + type PluginExtension, + type PluginExtensionComponent, + type PluginExtensionLink, + PluginExtensionTypes, +} from '@grafana/data'; + +export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink { + if (!extension) { + return false; + } + return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension); +} + +export function isPluginExtensionComponent( + extension: PluginExtension | undefined +): extension is PluginExtensionComponent { + if (!extension) { + return false; + } + return extension.type === PluginExtensionTypes.component && 'component' in extension; +} + +export function getLimitedComponentsToRender({ + props, + components, + limit, + pluginId, +}: { + props: Props; + components: Array>; + limit?: number; + pluginId?: string | string[] | RegExp; +}) { + if (!components.length) { + return null; + } + + const renderedComponents: Array> = []; + + for (const Component of components) { + const { meta } = Component; + + if (pluginId && typeof pluginId === 'string' && pluginId !== meta.pluginId) { + continue; + } + + if (pluginId && Array.isArray(pluginId) && !pluginId.includes(meta.pluginId)) { + continue; + } + + if (pluginId instanceof RegExp && !pluginId.test(meta.pluginId)) { + continue; + } + + // If no limit is provided, return all components + if (limit === undefined) { + renderedComponents.push(Component); + continue; + } + + // If a component does not render anything, do not count it in the limit + if (React.createElement(Component, props) !== null) { + renderedComponents.push(Component); + } + + // Stop if we've reached the limit + if (renderedComponents.length >= limit) { + break; + } + } + + return renderedComponents; +} + +export function renderLimitedComponents({ + props, + components, + limit, + pluginId, +}: { + props: Props; + components: Array>; + limit?: number; + pluginId?: string | string[] | RegExp; +}) { + const limitedComponents = getLimitedComponentsToRender({ props, components, limit, pluginId }); + + if (!limitedComponents?.length) { + return null; + } + + return ( + <> + {limitedComponents.map((Component) => ( + + ))} + + ); +} diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 46093ff35ba..aabd88e681f 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -11,6 +11,7 @@ import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginContextProvider, + PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context, EventBusExtended, EventBusSrv, HistoryItem, @@ -21,9 +22,17 @@ import { TimeRange, getDataSourceRef, toLegacyResponseData, + PluginExtensionPoints, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { AngularComponent, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { + AngularComponent, + getAngularLoader, + getDataSourceSrv, + renderLimitedComponents, + reportInteraction, + usePluginComponents, +} from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { Badge, ErrorBoundaryAlert } from '@grafana/ui'; import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; @@ -458,6 +467,7 @@ export class QueryEditorRow extends PureComponent); return extraActions; }; @@ -664,3 +674,27 @@ function MaybeQueryLibrarySaveButton(props: { query: DataQuery }) { const { renderSaveQueryButton } = useQueryLibraryContext(); return renderSaveQueryButton(props.query); } + +function AdaptiveTelemetryQueryActions({ query }: { query: DataQuery }) { + try { + const { isLoading, components } = usePluginComponents({ + extensionPointId: PluginExtensionPoints.QueryEditorRowAdaptiveTelemetryV1, + }); + + if (isLoading || !components.length) { + return null; + } + + return renderLimitedComponents({ + props: { query, contextHints: ['queryeditorrow', 'header'] }, + components, + limit: 1, + pluginId: /grafana-adaptive.*/, + }); + } catch (error) { + // If `usePluginComponents` isn't properly resolved, tests will fail with 'setPluginComponentsHook(options) can only be used after the Grafana instance has started.' + // This will be resolved in https://github.com/grafana/grafana/pull/92983 + // In this case, Return `null` like when there are no extensions. + return null; + } +}