Compare commits

...

8 Commits

Author SHA1 Message Date
Collin Fingar 4194d46ce0 Updates per feedback - link num 2026-01-12 14:47:53 -05:00
Collin Fingar b1000e53ad Merge branch 'main' of github.com:grafana/grafana into collinfingar/saved-queries-drilldown-extension-hook-poc 2026-01-12 14:42:13 -05:00
Collin Fingar 5556e249a8 Updated unit test 2026-01-07 16:04:17 -05:00
Collin Fingar 5038f697ba Added click handler. Updated name to 'Drilldown' to better fit tight spaces 2026-01-07 15:54:09 -05:00
Collin Fingar 92a7a7d5cf Merge branch 'main' of github.com:grafana/grafana into collinfingar/saved-queries-drilldown-extension-hook-poc 2026-01-07 09:01:07 -05:00
Collin Fingar ced15ae5ff Fixed tests / added i18n 2026-01-07 09:00:46 -05:00
Collin Fingar d133e1dce9 Updated POC to original funcitonal component 2026-01-06 16:42:44 -05:00
Collin Fingar 6fc7627bec POC: Added drilldown extension hook 2026-01-05 15:31:23 -05:00
3 changed files with 304 additions and 1 deletions
@@ -0,0 +1,233 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data';
import { setPluginLinksHook } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { DrilldownExtensionPoint } from './DrilldownExtensionPoint';
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 mockGlobalOpen = jest.fn();
global.open = mockGlobalOpen;
let usePluginLinksMock: jest.Mock;
beforeAll(() => {
usePluginLinksMock = jest.fn().mockReturnValue({ links: [], isLoading: false });
setPluginLinksHook(usePluginLinksMock);
});
afterEach(() => {
usePluginLinksMock.mockClear();
usePluginLinksMock.mockReturnValue({ links: [], isLoading: false });
mockGlobalOpen.mockClear();
});
describe('DrilldownExtensionPoint', () => {
const defaultQueries: DataQuery[] = [{ refId: 'A' }];
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(<DrilldownExtensionPoint queries={defaultQueries} />);
expect(screen.getByRole('button', { name: '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(<DrilldownExtensionPoint queries={defaultQueries} />);
await user.click(screen.getByRole('button', { name: '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(<DrilldownExtensionPoint queries={defaultQueries} />);
await user.click(screen.getByRole('button', { name: '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(<DrilldownExtensionPoint queries={defaultQueries} />);
expect(usePluginLinksMock).toHaveBeenCalledWith({
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
context: {
targets: defaultQueries,
timeRange: { from: 'now-1h', to: 'now' },
timeZone: 'browser',
},
});
});
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(<DrilldownExtensionPoint queries={defaultQueries} />);
expect(screen.queryByRole('button', { name: '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(<DrilldownExtensionPoint queries={defaultQueries} />);
expect(screen.queryByRole('button', { name: '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(<DrilldownExtensionPoint queries={defaultQueries} />);
await user.click(screen.getByRole('button', { name: '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(<DrilldownExtensionPoint queries={queries1} />);
expect(usePluginLinksMock).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
targets: queries1,
}),
})
);
rerender(<DrilldownExtensionPoint queries={queries2} />);
expect(usePluginLinksMock).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
targets: queries2,
}),
})
);
});
});
@@ -0,0 +1,69 @@
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[];
onExtensionClick?: () => void;
};
const QUERYLESS_APPS = [
'grafana-pyroscope-app',
'grafana-lokiexplore-app',
'grafana-exploretraces-app',
'grafana-metricsdrilldown-app',
];
/**
* Renders a button to open queryless drilldown apps.
* Only displays when at least one queryless app extension is available.
*/
export function DrilldownExtensionPoint(props: Props): ReactElement | null {
const { onExtensionClick } = props;
const context = useExtensionPointContext(props);
const { links } = usePluginLinks({
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
context: context,
});
const querylessLinks = useMemo(() => links.filter((link) => QUERYLESS_APPS.includes(link.pluginId)), [links]);
const onClick = useCallback(() => {
onExtensionClick?.();
const firstLink = querylessLinks[0];
if (!firstLink?.path) {
return;
}
global.open(locationUtil.assureBaseUrl(firstLink.path), '_blank');
}, [querylessLinks, onExtensionClick]);
if (!querylessLinks.length) {
return null;
}
return (
<Button variant="secondary" onClick={onClick}>
<Trans i18nKey="explore.queryless-apps-extensions.drilldown">Drilldown</Trans>
</Button>
);
}
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]);
}
+2 -1
View File
@@ -7518,7 +7518,8 @@
"loading-placeholder": "Loading..."
},
"queryless-apps-extensions": {
"aria-label-go-queryless": "Go queryless"
"aria-label-go-queryless": "Go queryless",
"drilldown": "Drilldown"
},
"raw-list-container": {
"item-count": "Result series: {{numItems}}",