diff --git a/.betterer.results b/.betterer.results
index cc2d71fb109..510154a2371 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -3148,9 +3148,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with ", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "3"]
],
- "public/app/features/explore/extensions/ToolbarExtensionPoint.tsx:5381": [
- [0, 0, 0, "No untranslated strings. Wrap text with ", "0"]
- ],
"public/app/features/explore/hooks/useStateSync/index.ts:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./external.utils\`)", "0"]
],
diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx
index 91dc7f35ec7..92e5204d742 100644
--- a/public/app/features/explore/Explore.test.tsx
+++ b/public/app/features/explore/Explore.test.tsx
@@ -180,7 +180,7 @@ describe('Explore', () => {
});
it('should render toolbar extension point if extensions is available', async () => {
- usePluginLinksMock.mockReturnValueOnce({
+ usePluginLinksMock.mockReturnValue({
links: [
{
id: '1',
diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx
index 9345d58b2c1..2cfd2cb39ce 100644
--- a/public/app/features/explore/ExploreToolbar.tsx
+++ b/public/app/features/explore/ExploreToolbar.tsx
@@ -255,6 +255,12 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
hideTextValue={showSmallDataSourcePicker}
width={showSmallDataSourcePicker ? 8 : undefined}
/>,
+ ,
].filter(Boolean)}
forceShowLeftItems
>
@@ -295,7 +301,12 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
),
- ,
+ ,
!isLive && (
{children}, {});
}
+function setupToolbarExtensionPoint(
+ { noTimezone, showQuerylessApps }: { noTimezone?: boolean; showQuerylessApps?: boolean } = { noTimezone: false }
+) {
+ return (
+
+ );
+}
+
describe('ToolbarExtensionPoint', () => {
describe('with extension points', () => {
beforeAll(() => {
@@ -79,13 +91,13 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "Add" extension point menu button', () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible();
});
it('should render menu with extensions when "Add" is clicked', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
@@ -95,7 +107,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should call onClick from extension when menu item is clicked', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Add to dashboard' }));
@@ -109,7 +121,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render confirm navigation modal when extension with path is clicked', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'ML: Forecast' }));
@@ -123,7 +135,7 @@ describe('ToolbarExtensionPoint', () => {
const targets = [{ refId: 'A' }];
const data = createEmptyQueryResponse();
- renderWithExploreStore(, {
+ renderWithExploreStore(setupToolbarExtensionPoint(), {
targets,
data,
});
@@ -148,7 +160,7 @@ describe('ToolbarExtensionPoint', () => {
const targets = [{ refId: 'A' }];
const data = createEmptyQueryResponse();
- renderWithExploreStore(, {
+ renderWithExploreStore(setupToolbarExtensionPoint({ noTimezone: true }), {
targets,
data,
});
@@ -160,7 +172,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should correct extension point id when fetching extensions', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
const [options] = usePluginLinksMock.mock.calls[0];
const { extensionPointId } = options;
@@ -195,13 +207,13 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "Add" extension point menu button', () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible();
});
it('should render menu with extensions when "Add" is clicked', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
@@ -219,7 +231,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "add to dashboard" action button if one pane is visible', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await waitFor(() => {
const button = screen.getByRole('button', { name: /add to dashboard/i });
@@ -237,9 +249,119 @@ describe('ToolbarExtensionPoint', () => {
});
it('should not render "add to dashboard" action button', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.queryByRole('button', { name: /add to dashboard/i })).not.toBeInTheDocument();
});
});
+
+ describe('with multiple queryless apps links', () => {
+ beforeAll(() => {
+ usePluginLinksMock.mockReturnValue({
+ links: [
+ {
+ pluginId: 'grafana',
+ id: '1',
+ type: PluginExtensionTypes.link,
+ title: 'Add to dashboard',
+ category: 'Dashboards',
+ description: 'Add the current query as a panel to a dashboard',
+ onClick: jest.fn(),
+ },
+ {
+ pluginId: 'grafana-ml-app',
+ id: '2',
+ type: PluginExtensionTypes.link,
+ title: 'ML: Forecast',
+ description: 'Add the query as a ML forecast',
+ path: '/a/grafana-ml-ap/forecast',
+ },
+ {
+ pluginId: 'grafana-pyroscope-app',
+ id: '3',
+ type: PluginExtensionTypes.link,
+ title: 'Explore Profiles',
+ description: 'Explore Profiles',
+ path: '/a/grafana-pyroscope-app',
+ },
+ {
+ pluginId: 'grafana-lokiexplore-app',
+ id: '4',
+ type: PluginExtensionTypes.link,
+ title: 'Explore Logs',
+ description: 'Explore Logs',
+ path: '/a/grafana-lokiexplore-app',
+ },
+ ],
+ isLoading: false,
+ });
+ });
+
+ it('should render menu with extensions without queryless apps when "Add" is clicked', async () => {
+ renderWithExploreStore(setupToolbarExtensionPoint());
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add' }));
+
+ expect(screen.getByRole('group', { name: 'Dashboards' })).toBeVisible();
+ expect(screen.getByRole('menuitem', { name: 'Add to dashboard' })).toBeVisible();
+ expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible();
+ expect(screen.queryByRole('menuitem', { name: 'Explore Profiles' })).not.toBeInTheDocument();
+ });
+
+ it('should render queryless apps links', async () => {
+ renderWithExploreStore(setupToolbarExtensionPoint({ showQuerylessApps: true }));
+
+ await userEvent.click(screen.getByRole('button', { name: /go queryless/i }));
+
+ expect(screen.queryByRole('group', { name: 'Dashboards' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Add to dashboard' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'ML: Forecast' })).not.toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: 'Explore Profiles' })).toBeVisible();
+ expect(screen.getByRole('menuitem', { name: 'Explore Logs' })).toBeVisible();
+ });
+ });
+
+ describe('with single queryless apps link', () => {
+ beforeAll(() => {
+ usePluginLinksMock.mockReturnValue({
+ links: [
+ {
+ pluginId: 'grafana',
+ id: '1',
+ type: PluginExtensionTypes.link,
+ title: 'Add to dashboard',
+ category: 'Dashboards',
+ description: 'Add the current query as a panel to a dashboard',
+ onClick: jest.fn(),
+ },
+ {
+ pluginId: 'grafana-ml-app',
+ id: '2',
+ type: PluginExtensionTypes.link,
+ title: 'ML: Forecast',
+ description: 'Add the query as a ML forecast',
+ path: '/a/grafana-ml-ap/forecast',
+ },
+ {
+ pluginId: 'grafana-pyroscope-app',
+ id: '3',
+ type: PluginExtensionTypes.link,
+ title: 'Explore Profiles',
+ description: 'Explore Profiles',
+ path: '/a/grafana-pyroscope-app',
+ },
+ ],
+ isLoading: false,
+ });
+ });
+
+ it('should render single queryless app link', async () => {
+ renderWithExploreStore(setupToolbarExtensionPoint({ showQuerylessApps: true }));
+
+ await userEvent.click(screen.getByRole('button', { name: /go queryless/i }));
+
+ expect(screen.queryByRole('menuitem', { name: 'Explore Profiles' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Explore Logs' })).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
index 399ba36ee9a..35d027b2076 100644
--- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
+++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
@@ -1,66 +1,69 @@
-import { lazy, ReactElement, Suspense, useMemo, useState } from 'react';
+import { ReactElement, useMemo, useState } from 'react';
import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data';
-import { config, usePluginLinks } from '@grafana/runtime';
+import { config, reportInteraction, usePluginLinks } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
-import { Dropdown, ToolbarButton } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, ExplorePanelData, useSelector } from 'app/types';
import { getExploreItemSelector, isLeftPaneSelector, selectCorrelationDetails } from '../state/selectors';
import { ConfirmNavigationModal } from './ConfirmNavigationModal';
-import { ToolbarExtensionPointMenu } from './ToolbarExtensionPointMenu';
-
-const AddToDashboard = lazy(() =>
- import('./AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard }))
-);
+import { BasicExtensions } from './toolbar/BasicExtensions';
+import { QuerylessAppsExtensions } from './toolbar/QuerylessAppsExtensions';
type Props = {
exploreId: string;
timeZone: TimeZone;
+ extensionsToShow: 'queryless' | 'basic';
};
+const QUERYLESS_APPS = ['grafana-pyroscope-app', 'grafana-lokiexplore-app', 'grafana-exploretraces-app'];
+
export function ToolbarExtensionPoint(props: Props): ReactElement | null {
- const { exploreId } = props;
+ const { exploreId, extensionsToShow } = props;
const [selectedExtension, setSelectedExtension] = useState();
const [isOpen, setIsOpen] = useState(false);
const context = useExtensionPointContext(props);
+ // TODO: Pull it up to avoid calling it twice
const { links } = usePluginLinks({
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
context: context,
limitPerPlugin: 3,
});
const selectExploreItem = getExploreItemSelector(exploreId);
- const noQueriesInPane = useSelector(selectExploreItem)?.queries?.length;
+ const noQueriesInPane = Boolean(useSelector(selectExploreItem)?.queries?.length);
- // If we only have the explore core extension point registered we show the old way of
- // adding a query to a dashboard.
- if (links.length <= 1) {
- const canAddPanelToDashboard =
- contextSrv.hasPermission(AccessControlAction.DashboardsCreate) ||
- contextSrv.hasPermission(AccessControlAction.DashboardsWrite);
-
- if (!canAddPanelToDashboard) {
- return null;
- }
-
- return (
-
-
-
- );
- }
-
- const menu = ;
+ const querylessLinks = links.filter((link) => QUERYLESS_APPS.includes(link.pluginId));
+ const commonLinks = links.filter((link) => !QUERYLESS_APPS.includes(link.pluginId));
return (
<>
-
-
- Add
-
-
+ {extensionsToShow === 'queryless' && (
+ {
+ setSelectedExtension(extension);
+ reportInteraction('grafana_explore_queryless_app_link_clicked', {
+ pluginId: extension.pluginId,
+ });
+ }}
+ setIsModalOpen={setIsOpen}
+ isModalOpen={isOpen}
+ />
+ )}
+ {extensionsToShow === 'basic' && (
+
+ )}
{!!selectedExtension && !!selectedExtension.path && (
+ import('./../AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard }))
+);
+
+export function BasicExtensions(props: ExtensionDropdownProps) {
+ const { exploreId, links, setSelectedExtension, setIsModalOpen, isModalOpen, noQueriesInPane } = props;
+ // If we only have the explore core extension point registered we show the old way of
+ // adding a query to a dashboard.
+ if (links.length <= 1) {
+ const canAddPanelToDashboard =
+ contextSrv.hasPermission(AccessControlAction.DashboardsCreate) ||
+ contextSrv.hasPermission(AccessControlAction.DashboardsWrite);
+
+ if (!canAddPanelToDashboard) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ const menu = ;
+
+ return (
+ <>
+
+
+ Add
+
+
+ >
+ );
+}
diff --git a/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx b/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx
new file mode 100644
index 00000000000..7433c74393c
--- /dev/null
+++ b/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx
@@ -0,0 +1,42 @@
+import { first } from 'lodash';
+
+import { Dropdown, ToolbarButton } from '@grafana/ui';
+
+import { Trans } from '../../../../core/internationalization';
+import { ToolbarExtensionPointMenu } from '../ToolbarExtensionPointMenu';
+
+import { ExtensionDropdownProps } from './types';
+
+export function QuerylessAppsExtensions(props: ExtensionDropdownProps) {
+ const { links, setSelectedExtension, setIsModalOpen, isModalOpen, noQueriesInPane } = props;
+
+ if (links.length === 0) {
+ return undefined;
+ }
+
+ const menu = ;
+
+ if (links.length === 1) {
+ const link = first(links)!;
+ return (
+ setSelectedExtension(link)}>
+ Go queryless
+
+ );
+ }
+
+ return (
+ <>
+
+
+ Go queryless
+
+
+ >
+ );
+}
diff --git a/public/app/features/explore/extensions/toolbar/types.ts b/public/app/features/explore/extensions/toolbar/types.ts
new file mode 100644
index 00000000000..a23b83b51bd
--- /dev/null
+++ b/public/app/features/explore/extensions/toolbar/types.ts
@@ -0,0 +1,10 @@
+import { PluginExtensionLink } from '@grafana/data';
+
+export type ExtensionDropdownProps = {
+ links: PluginExtensionLink[];
+ exploreId: string;
+ setSelectedExtension: (extension: PluginExtensionLink) => void;
+ setIsModalOpen: (value: boolean) => void;
+ isModalOpen: boolean;
+ noQueriesInPane: boolean;
+};
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index f430b69d65d..f747547a8a0 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -1261,6 +1261,8 @@
"title-with-name": "Table - {{name}}"
},
"toolbar": {
+ "add-to-extensions": "Add",
+ "add-to-queryless-extensions": "Go queryless",
"aria-label": "Explore toolbar",
"copy-link": "Copy URL",
"copy-link-abs-time": "Copy absolute URL",
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index fe6defcf56c..e73d0cf088e 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -1261,6 +1261,8 @@
"title-with-name": "Ŧäþľę - {{name}}"
},
"toolbar": {
+ "add-to-extensions": "Åđđ",
+ "add-to-queryless-extensions": "Ğő qūęřyľęşş",
"aria-label": "Ēχpľőřę ŧőőľþäř",
"copy-link": "Cőpy ŮŖĿ",
"copy-link-abs-time": "Cőpy äþşőľūŧę ŮŖĿ",