From bb673fc8ed9c7488263011cad938f4f6d4b07a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Wed, 11 Dec 2024 13:53:28 +0100 Subject: [PATCH] Explore: Show links to queryless apps (#96625) * Extract basic extensions to a separate files * Add simple queryless apps links * Move links for queryless apps next to the datasource picker * Update tests * Add translations * Add tracking * Update translations * Fix tests and betterer * Fix the mock for the test (the hook may be called twice now) * Add a todo --- .betterer.results | 3 - public/app/features/explore/Explore.test.tsx | 2 +- .../app/features/explore/ExploreToolbar.tsx | 13 +- .../extensions/ToolbarExtensionPoint.test.tsx | 144 ++++++++++++++++-- .../extensions/ToolbarExtensionPoint.tsx | 71 ++++----- .../extensions/toolbar/BasicExtensions.tsx | 47 ++++++ .../toolbar/QuerylessAppsExtensions.tsx | 42 +++++ .../explore/extensions/toolbar/types.ts | 10 ++ public/locales/en-US/grafana.json | 2 + public/locales/pseudo-LOCALE/grafana.json | 2 + 10 files changed, 286 insertions(+), 50 deletions(-) create mode 100644 public/app/features/explore/extensions/toolbar/BasicExtensions.tsx create mode 100644 public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx create mode 100644 public/app/features/explore/extensions/toolbar/types.ts 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 äþşőľūŧę ŮŖĿ",