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 <balogh.levente.hu@gmail.com>
This commit is contained in:
Darren Janeczek
2025-03-18 12:45:01 -04:00
committed by GitHub
co-authored by Levente Balogh
parent a7a9f9b078
commit e948376a95
8 changed files with 421 additions and 74 deletions
+1
View File
@@ -557,6 +557,7 @@ export {
type PluginExtensionComponentConfig,
type PluginExtensionEventHelpers,
type PluginExtensionPanelContext,
type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context,
type PluginExtensionDataSourceConfigContext,
type PluginExtensionCommandPaletteContext,
type PluginExtensionOpenModalOptions,
@@ -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 = {},
@@ -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';
@@ -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);
});
});
});
@@ -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<ComponentTypeWithExtensionMeta<{}>> = [];
const limitedComponents = getLimitedComponentsToRender({ props, components });
expect(limitedComponents).toEqual(null);
});
test('should return all components if no limit is provided', () => {
const props = {};
const components: Array<ComponentTypeWithExtensionMeta<{}>> = [
createComponent(() => <div>Test 1</div>, undefined, 'id-1'),
createComponent(() => <div>Test 2</div>, undefined, 'id-2'),
createComponent(() => <div>Test 3</div>, undefined, 'id-3'),
];
expect(getLimitedComponentsToRender({ props, components })?.length).toEqual(3);
});
test('should limit the number of components', () => {
const props = {};
const components: Array<ComponentTypeWithExtensionMeta<{}>> = [
createComponent(() => <div>Test 1</div>, undefined, 'id-1'),
createComponent(() => <div>Test 2</div>, undefined, 'id-2'),
createComponent(() => <div>Test 3</div>, undefined, 'id-3'),
createComponent(() => <div>Test 4</div>, undefined, 'id-4'),
createComponent(() => <div>Test 5</div>, 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) => <Component key={index} {...props} />)}</>
);
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 <div>Test 1</div>;
}
};
const Component2 = class extends React.Component<{}> {
render() {
return <div>Test 2</div>;
}
};
const components: Array<ComponentTypeWithExtensionMeta<{}>> = [
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) => <Component key={index} {...props} />)}</>
);
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<ComponentTypeWithExtensionMeta<{}>> = [
createComponent(() => <div>Test 1</div>, 'plugin-id-1', 'id-1'),
createComponent(() => <div>Test 2</div>, 'plugin-id-2', 'id-2'),
createComponent(() => <div>Test 3</div>, 'plugin-id-3', 'id-3'),
createComponent(() => <div>Test 4</div>, 'plugin-id-4', 'id-4'),
createComponent(() => <div>Test 5</div>, '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) => <Component key={index} {...props} />)}</>
);
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<ComponentTypeWithExtensionMeta<{}>> = [
createComponent(() => <div>Test 1</div>, 'plugin-id-1', 'id-1'),
createComponent(() => <div>Test 2</div>, 'plugin-id-2', 'id-2'),
createComponent(() => <div>Test 3</div>, 'plugin-id-3', 'id-3'),
createComponent(() => <div>Test 4</div>, 'plugin-id-4', 'id-4'),
createComponent(() => <div>Test 5</div>, '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<ComponentTypeWithExtensionMeta<{}>> = [
createComponent(() => <div>Test 1</div>, 'plugin-id-1', 'id-1'),
createComponent(() => <div>Test 2</div>, 'plugin-id-1', 'id-2'),
createComponent(() => <div>Test 3</div>, 'plugin-id-2', 'id-3'),
createComponent(() => <div>Test 4</div>, '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<ComponentTypeWithExtensionMeta<{}>> = [
createComponent(() => <div>Test 1</div>, 'plugin-id-1', 'id-1'),
createComponent(() => <div>Test 2</div>, 'plugin-id-2', 'id-2'),
createComponent(() => <div>Test 3</div>, '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<ComponentTypeWithExtensionMeta<{}>> = [
createComponent(() => <div>Test 1</div>, 'plugin-id-1', 'id-1'),
createComponent(() => <div>Test 2</div>, 'plugin-id-2', 'id-2'),
createComponent(() => <div>Test 3</div>, '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<Props extends JSX.IntrinsicAttributes>(
Implementation?: React.ComponentType<Props>,
pluginId?: string,
id?: string
): ComponentTypeWithExtensionMeta<Props> {
function ComponentWithMeta(props: Props) {
if (Implementation) {
return <Implementation {...props} />;
}
return <div>Test</div>;
}
ComponentWithMeta.displayName = '';
ComponentWithMeta.propTypes = {};
ComponentWithMeta.contextTypes = {};
ComponentWithMeta.meta = {
id: id ?? '',
pluginId: pluginId ?? '',
title: '',
description: '',
type: PluginExtensionTypes.component,
} satisfies PluginExtensionComponentMeta;
return ComponentWithMeta;
}
@@ -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;
}
@@ -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 extends {}>({
props,
components,
limit,
pluginId,
}: {
props: Props;
components: Array<ComponentTypeWithExtensionMeta<Props>>;
limit?: number;
pluginId?: string | string[] | RegExp;
}) {
if (!components.length) {
return null;
}
const renderedComponents: Array<ComponentTypeWithExtensionMeta<Props>> = [];
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<Props>(Component, props) !== null) {
renderedComponents.push(Component);
}
// Stop if we've reached the limit
if (renderedComponents.length >= limit) {
break;
}
}
return renderedComponents;
}
export function renderLimitedComponents<Props extends {}>({
props,
components,
limit,
pluginId,
}: {
props: Props;
components: Array<ComponentTypeWithExtensionMeta<Props>>;
limit?: number;
pluginId?: string | string[] | RegExp;
}) {
const limitedComponents = getLimitedComponentsToRender({ props, components, limit, pluginId });
if (!limitedComponents?.length) {
return null;
}
return (
<>
{limitedComponents.map((Component) => (
<Component key={Component.meta.id} {...props} />
))}
</>
);
}
@@ -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<TQuery extends DataQuery> extends PureComponent<Prop
extraActions.push(this.renderWarnings('info'));
extraActions.push(this.renderWarnings('warning'));
extraActions.push(<AdaptiveTelemetryQueryActions key="adaptive-telemetry-actions" query={query} />);
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<PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context>({
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;
}
}