diff --git a/public/app/features/explore/extensions/DrilldownExtensionPoint.test.tsx b/public/app/features/explore/extensions/DrilldownExtensionPoint.test.tsx new file mode 100644 index 00000000000..29e2039d156 --- /dev/null +++ b/public/app/features/explore/extensions/DrilldownExtensionPoint.test.tsx @@ -0,0 +1,232 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data'; +import { usePluginLinks } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; + +import { DrilldownExtensionPoint } from './DrilldownExtensionPoint'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + usePluginLinks: jest.fn(), +})); + +jest.mock('@grafana/data', () => ({ + ...jest.requireActual('@grafana/data'), + getDefaultTimeRange: jest.fn(() => ({ + raw: { from: 'now-1h', to: 'now' }, + })), + getTimeZone: jest.fn(() => 'browser'), + locationUtil: { + assureBaseUrl: jest.fn((path: string) => `http://localhost${path}`), + }, +})); + +const usePluginLinksMock = jest.mocked(usePluginLinks); + +const mockGlobalOpen = jest.fn(); +global.open = mockGlobalOpen; + +describe('DrilldownExtensionPoint', () => { + const defaultQueries: DataQuery[] = [{ refId: 'A' }]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the button when queryless app links are available', () => { + usePluginLinksMock.mockReturnValue({ + links: [ + { + pluginId: 'grafana-pyroscope-app', + id: '1', + type: PluginExtensionTypes.link, + title: 'Explore Profiles', + description: 'Explore Profiles', + path: '/a/grafana-pyroscope-app', + }, + ], + isLoading: false, + }); + + render(); + + expect(screen.getByRole('button', { name: 'Open in Drilldown' })).toBeVisible(); + }); + + it('should open the first queryless app link when button is clicked', async () => { + const user = userEvent.setup(); + usePluginLinksMock.mockReturnValue({ + links: [ + { + pluginId: 'grafana-pyroscope-app', + id: '1', + type: PluginExtensionTypes.link, + title: 'Explore Profiles', + description: 'Explore Profiles', + path: '/a/grafana-pyroscope-app', + }, + ], + isLoading: false, + }); + + render(); + await user.click(screen.getByRole('button', { name: 'Open in Drilldown' })); + + expect(mockGlobalOpen).toHaveBeenCalledTimes(1); + expect(mockGlobalOpen).toHaveBeenCalledWith('http://localhost/a/grafana-pyroscope-app', '_blank'); + }); + + it('should open the first link when multiple queryless app links are available', async () => { + const user = userEvent.setup(); + usePluginLinksMock.mockReturnValue({ + links: [ + { + pluginId: 'grafana-pyroscope-app', + id: '1', + type: PluginExtensionTypes.link, + title: 'Explore Profiles', + path: '/a/grafana-pyroscope-app', + description: 'Explore Profiles', + }, + { + pluginId: 'grafana-lokiexplore-app', + id: '2', + type: PluginExtensionTypes.link, + title: 'Explore Logs', + path: '/a/grafana-lokiexplore-app', + description: 'Explore Logs', + }, + ], + isLoading: false, + }); + + render(); + await user.click(screen.getByRole('button', { name: 'Open in Drilldown' })); + + expect(mockGlobalOpen).toHaveBeenCalledWith('http://localhost/a/grafana-pyroscope-app', '_blank'); + expect(mockGlobalOpen).toHaveBeenCalledTimes(1); + }); + + it('should pass correct context to usePluginLinks', () => { + usePluginLinksMock.mockReturnValue({ + links: [ + { + pluginId: 'grafana-pyroscope-app', + id: '1', + type: PluginExtensionTypes.link, + title: 'Explore Profiles', + description: 'Explore Profiles', + path: '/a/grafana-pyroscope-app', + }, + ], + isLoading: false, + }); + + render(); + + expect(usePluginLinksMock).toHaveBeenCalledWith({ + extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + context: { + targets: defaultQueries, + timeRange: { from: 'now-1h', to: 'now' }, + timeZone: 'browser', + }, + limitPerPlugin: 3, + }); + }); + + it('should not render the button when no queryless app links are available', () => { + usePluginLinksMock.mockReturnValue({ + links: [ + { + pluginId: 'other-plugin', + id: '1', + type: PluginExtensionTypes.link, + title: 'Other Extension', + path: '/a/other-plugin', + description: 'Other Extension', + }, + ], + isLoading: false, + }); + + const { container } = render(); + + expect(screen.queryByRole('button', { name: 'Open in Drilldown' })).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); + + it('should not render the button when links array is empty', () => { + usePluginLinksMock.mockReturnValue({ + links: [], + isLoading: false, + }); + + const { container } = render(); + + expect(screen.queryByRole('button', { name: 'Open in Drilldown' })).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); + + it('should not call global.open when link has no path', async () => { + const user = userEvent.setup(); + usePluginLinksMock.mockReturnValue({ + links: [ + { + pluginId: 'grafana-pyroscope-app', + id: '1', + type: PluginExtensionTypes.link, + title: 'Explore Profiles', + description: 'Explore Profiles', + }, + ], + isLoading: false, + }); + + render(); + await user.click(screen.getByRole('button', { name: 'Open in Drilldown' })); + + expect(mockGlobalOpen).not.toHaveBeenCalled(); + }); + + it('should update context when queries change', () => { + const queries1: DataQuery[] = [{ refId: 'A' }]; + const queries2: DataQuery[] = [{ refId: 'B' }]; + + usePluginLinksMock.mockReturnValue({ + links: [ + { + pluginId: 'grafana-pyroscope-app', + id: '1', + type: PluginExtensionTypes.link, + title: 'Explore Profiles', + description: 'Explore Profiles', + path: '/a/grafana-pyroscope-app', + }, + ], + isLoading: false, + }); + + const { rerender } = render(); + + expect(usePluginLinksMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + targets: queries1, + }), + }) + ); + + rerender(); + + expect(usePluginLinksMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + targets: queries2, + }), + }) + ); + }); +}); diff --git a/public/app/features/explore/extensions/DrilldownExtensionPoint.tsx b/public/app/features/explore/extensions/DrilldownExtensionPoint.tsx index c7c13987a99..3b9f33bd1b8 100644 --- a/public/app/features/explore/extensions/DrilldownExtensionPoint.tsx +++ b/public/app/features/explore/extensions/DrilldownExtensionPoint.tsx @@ -1,8 +1,14 @@ -import { useCallback, useMemo } from 'react'; +import { ReactElement, useCallback, useMemo } from 'react'; import { PluginExtensionPoints, RawTimeRange, getDefaultTimeRange, getTimeZone, locationUtil } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { usePluginLinks } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; +import { Button } from '@grafana/ui'; + +type Props = { + queries: DataQuery[]; +}; const QUERYLESS_APPS = [ 'grafana-pyroscope-app', @@ -11,33 +17,12 @@ const QUERYLESS_APPS = [ 'grafana-metricsdrilldown-app', ]; -export type PluginExtensionExploreContext = { - targets: DataQuery[]; - timeRange: RawTimeRange; - timeZone: TimeZone; -}; - /** - * Creates the extension point context for drilldown actions. - * @param queries - The data queries to include in the context - * @returns The extension point context + * Renders a button to open queryless drilldown apps. + * Only displays when at least one queryless app extension is available. */ -export function createExtensionPointContext(queries: DataQuery[]): PluginExtensionExploreContext { - const range = getDefaultTimeRange(); - return { - targets: queries, - timeRange: range.raw, - timeZone: getTimeZone(), - }; -} - -/** - * Hook that returns queryless drilldown links and handlers. - * @param queries - The data queries to use for the extension point context - * @returns An object containing the queryless links and an onClick handler, or null if no links are available - */ -export function useDrilldownExtensionPoint(queries: DataQuery[]) { - const context = useMemo(() => createExtensionPointContext(queries), [queries]); +export function DrilldownExtensionPoint(props: Props): ReactElement | null { + const context = useExtensionPointContext(props); const { links } = usePluginLinks({ extensionPointId: PluginExtensionPoints.ExploreToolbarAction, context: context, @@ -46,19 +31,37 @@ export function useDrilldownExtensionPoint(queries: DataQuery[]) { const querylessLinks = useMemo(() => links.filter((link) => QUERYLESS_APPS.includes(link.pluginId)), [links]); const onClick = useCallback(() => { - if (!querylessLinks.length || !querylessLinks[0].path) { + const firstLink = querylessLinks[0]; + if (!firstLink?.path) { return; } - global.open(locationUtil.assureBaseUrl(querylessLinks[0].path), '_blank'); + global.open(locationUtil.assureBaseUrl(firstLink.path), '_blank'); }, [querylessLinks]); if (!querylessLinks.length) { return null; } - return { - links: querylessLinks, - onClick, - primaryLink: querylessLinks[0], - }; + return ( + + ); +} + +export type PluginExtensionExploreContext = { + targets: DataQuery[]; + timeRange: RawTimeRange; + timeZone: TimeZone; +}; + +function useExtensionPointContext({ queries }: Props): PluginExtensionExploreContext { + return useMemo(() => { + const range = getDefaultTimeRange(); + return { + targets: queries, + timeRange: range.raw, + timeZone: getTimeZone(), + }; + }, [queries]); }