diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.ts b/public/app/features/plugins/extensions/getPluginExtensions.test.ts index ce106bce5a5..52408ecf6de 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.ts @@ -1,10 +1,18 @@ import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { createPluginExtensionRegistry } from './createPluginExtensionRegistry'; import { getPluginExtensions } from './getPluginExtensions'; import { isReadOnlyProxy } from './utils'; import { assertPluginExtensionLink } from './validators'; +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + }; +}); + describe('getPluginExtensions()', () => { const extensionPoint1 = 'grafana/dashboard/panel/menu'; const extensionPoint2 = 'plugins/myorg-basic-app/start'; @@ -30,6 +38,7 @@ describe('getPluginExtensions()', () => { }; global.console.warn = jest.fn(); + jest.mocked(reportInteraction).mockReset(); }); test('should return the extensions for the given placement', () => { @@ -43,7 +52,7 @@ describe('getPluginExtensions()', () => { type: PluginExtensionTypes.link, title: link1.title, description: link1.description, - path: link1.path, + path: expect.stringContaining(link1.path!), }) ); }); @@ -60,7 +69,7 @@ describe('getPluginExtensions()', () => { type: PluginExtensionTypes.link, title: link1.title, description: link1.description, - path: link1.path, + path: expect.stringContaining(link1.path!), }) ); }); @@ -89,7 +98,7 @@ describe('getPluginExtensions()', () => { type: PluginExtensionTypes.link, title: link1.title, description: link1.description, - path: link1.path, + path: expect.stringContaining(link1.path!), }) ); }); @@ -129,11 +138,32 @@ describe('getPluginExtensions()', () => { expect(link2.configure).toHaveBeenCalledTimes(1); expect(extension.title).toBe('Updated title'); expect(extension.description).toBe('Updated description'); - expect(extension.path).toBe(`/a/${pluginId}/updated-path`); + expect(extension.path?.startsWith(`/a/${pluginId}/updated-path`)).toBeTruthy(); expect(extension.icon).toBe('search'); expect(extension.category).toBe('Machine Learning'); }); + test('should append link tracking to path when running configure() function', () => { + link2.configure = jest.fn().mockImplementation(() => ({ + title: 'Updated title', + description: 'Updated description', + path: `/a/${pluginId}/updated-path`, + icon: 'search', + category: 'Machine Learning', + })); + + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const [extension] = extensions; + + assertPluginExtensionLink(extension); + + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(extension.path).toBe( + `/a/${pluginId}/updated-path?uel_pid=grafana-basic-app&uel_epid=plugins%2Fmyorg-basic-app%2Fstart` + ); + }); + test('should ignore restricted properties passed via the configure() function', () => { link2.configure = jest.fn().mockImplementation(() => ({ // The following props are not allowed to override @@ -332,7 +362,7 @@ describe('getPluginExtensions()', () => { }).toThrow(); }); - test('should should not make original context read only', () => { + test('should not make original context read only', () => { const context = { title: 'New title from the context!', nested: { title: 'title' }, @@ -348,4 +378,35 @@ describe('getPluginExtensions()', () => { context.array.push('b'); }).not.toThrow(); }); + + test('should report interaction when onClick is triggered', () => { + const reportInteractionMock = jest.mocked(reportInteraction); + + const registry = createPluginExtensionRegistry([ + { + pluginId, + extensionConfigs: [ + { + ...link1, + path: undefined, + onClick: jest.fn(), + }, + ], + }, + ]); + const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); + const [extension] = extensions; + + assertPluginExtensionLink(extension); + + extension.onClick?.(); + + expect(reportInteractionMock).toBeCalledTimes(1); + expect(reportInteractionMock).toBeCalledWith('ui_extension_link_clicked', { + pluginId: extension.pluginId, + extensionPointId: extensionPoint1, + title: extension.title, + category: extension.category, + }); + }); }); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 71acd6a365b..069635dea3d 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -1,10 +1,14 @@ +import { isString } from 'lodash'; + import { type PluginExtension, PluginExtensionTypes, type PluginExtensionLink, type PluginExtensionLinkConfig, type PluginExtensionComponent, + urlUtil, } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import type { PluginExtensionRegistry } from './types'; import { @@ -60,24 +64,25 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, // LINK if (isPluginExtensionLinkConfig(extensionConfig)) { // Run the configure() function with the current context, and apply the ovverides - const overrides = getLinkExtensionOverrides(registryItem.pluginId, extensionConfig, frozenContext); + const overrides = getLinkExtensionOverrides(pluginId, extensionConfig, frozenContext); // configure() returned an `undefined` -> hide the extension if (extensionConfig.configure && overrides === undefined) { continue; } + const path = overrides?.path || extensionConfig.path; const extension: PluginExtensionLink = { - id: generateExtensionId(registryItem.pluginId, extensionConfig), + id: generateExtensionId(pluginId, extensionConfig), type: PluginExtensionTypes.link, - pluginId: registryItem.pluginId, - onClick: getLinkExtensionOnClick(extensionConfig, frozenContext), + pluginId: pluginId, + onClick: getLinkExtensionOnClick(pluginId, extensionConfig, frozenContext), // Configurable properties icon: overrides?.icon || extensionConfig.icon, title: overrides?.title || extensionConfig.title, description: overrides?.description || extensionConfig.description, - path: overrides?.path || extensionConfig.path, + path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionConfig) : undefined, category: overrides?.category || extensionConfig.category, }; @@ -165,6 +170,7 @@ function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLink } function getLinkExtensionOnClick( + pluginId: string, config: PluginExtensionLinkConfig, context?: object ): ((event?: React.MouseEvent) => void) | undefined { @@ -176,6 +182,13 @@ function getLinkExtensionOnClick( return function onClickExtensionLink(event?: React.MouseEvent) { try { + reportInteraction('ui_extension_link_clicked', { + pluginId: pluginId, + extensionPointId: config.extensionPointId, + title: config.title, + category: config.category, + }); + const result = onClick(event, getEventHelpers(context)); if (isPromise(result)) { @@ -192,3 +205,13 @@ function getLinkExtensionOnClick( } }; } + +function getLinkExtensionPathWithTracking(pluginId: string, path: string, config: PluginExtensionLinkConfig): string { + return urlUtil.appendQueryToUrl( + path, + urlUtil.toUrlParams({ + uel_pid: pluginId, + uel_epid: config.extensionPointId, + }) + ); +}