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]);
}