diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx index cce2e4012f5..893a391da3b 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx @@ -60,7 +60,8 @@ describe('ToolbarExtensionPoint', () => { pluginId: 'grafana', id: '1', type: PluginExtensionTypes.link, - title: 'Dashboard', + title: 'Add to dashboard', + category: 'Dashboards', description: 'Add the current query as a panel to a dashboard', onClick: jest.fn(), }, @@ -82,12 +83,13 @@ describe('ToolbarExtensionPoint', () => { expect(screen.getByRole('button', { name: 'Add' })).toBeVisible(); }); - it('should render "Add" extension point menu button in split mode', async () => { + it('should render menu with extensions when "Add" is clicked in split mode', async () => { renderWithExploreStore(); await userEvent.click(screen.getByRole('button', { name: 'Add' })); - expect(screen.getByRole('menuitem', { name: 'Dashboard' })).toBeVisible(); + expect(screen.getByRole('group', { name: 'Dashboards' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'Add to dashboard' })).toBeVisible(); expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible(); }); @@ -96,7 +98,8 @@ describe('ToolbarExtensionPoint', () => { await userEvent.click(screen.getByRole('button', { name: 'Add' })); - expect(screen.getByRole('menuitem', { name: 'Dashboard' })).toBeVisible(); + expect(screen.getByRole('group', { name: 'Dashboards' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'Add to dashboard' })).toBeVisible(); expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible(); }); @@ -104,7 +107,7 @@ describe('ToolbarExtensionPoint', () => { renderWithExploreStore(); await userEvent.click(screen.getByRole('button', { name: 'Add' })); - await userEvent.click(screen.getByRole('menuitem', { name: 'Dashboard' })); + await userEvent.click(screen.getByRole('menuitem', { name: 'Add to dashboard' })); const { extensions } = getPluginLinkExtensions({ extensionPointId: PluginExtensionPoints.ExploreToolbarAction }); const [extension] = extensions; @@ -157,6 +160,59 @@ describe('ToolbarExtensionPoint', () => { }); }); + describe('with extension points without categories', () => { + beforeAll(() => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + pluginId: 'grafana', + id: '1', + type: PluginExtensionTypes.link, + title: 'Dashboard', + 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', + }, + ], + }); + }); + + it('should render "Add" extension point menu button', () => { + renderWithExploreStore(); + + expect(screen.getByRole('button', { name: 'Add' })).toBeVisible(); + }); + + it('should render "Add" extension point menu button in split mode', async () => { + renderWithExploreStore(); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + // Make sure we don't have anything related to categories rendered + expect(screen.queryAllByRole('group').length).toBe(0); + expect(screen.getByRole('menuitem', { name: 'Dashboard' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible(); + }); + + it('should render menu with extensions when "Add" is clicked', async () => { + renderWithExploreStore(); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + // Make sure we don't have anything related to categories rendered + expect(screen.queryAllByRole('group').length).toBe(0); + expect(screen.getByRole('menuitem', { name: 'Dashboard' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible(); + }); + }); + describe('without extension points', () => { beforeAll(() => { contextSrvMock.hasAccess.mockReturnValue(true); diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx index f360f839a39..0d6508b8acb 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -3,14 +3,14 @@ import React, { lazy, ReactElement, Suspense, useMemo, useState } from 'react'; import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange } from '@grafana/data'; import { getPluginLinkExtensions } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; -import { Dropdown, Menu, ToolbarButton } from '@grafana/ui'; +import { Dropdown, ToolbarButton } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; -import { truncateTitle } from 'app/features/plugins/extensions/utils'; import { AccessControlAction, ExplorePanelData, useSelector } from 'app/types'; import { getExploreItemSelector } from '../state/selectors'; import { ConfirmNavigationModal } from './ConfirmNavigationModal'; +import { ToolbarExtensionPointMenu } from './ToolbarExtensionPointMenu'; const AddToDashboard = lazy(() => import('./AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard })) @@ -49,24 +49,7 @@ export function ToolbarExtensionPoint(props: Props): ReactElement | null { ); } - const menu = ( - - {extensions.map((extension) => ( - { - if (extension.path) { - return setSelectedExtension(extension); - } - extension.onClick?.(event); - }} - /> - ))} - - ); + const menu = ; return ( <> diff --git a/public/app/features/explore/extensions/ToolbarExtensionPointMenu.tsx b/public/app/features/explore/extensions/ToolbarExtensionPointMenu.tsx new file mode 100644 index 00000000000..651cc5103e8 --- /dev/null +++ b/public/app/features/explore/extensions/ToolbarExtensionPointMenu.tsx @@ -0,0 +1,76 @@ +import React, { ReactElement, useMemo } from 'react'; + +import { PluginExtensionLink } from '@grafana/data'; +import { Menu } from '@grafana/ui'; +import { truncateTitle } from 'app/features/plugins/extensions/utils'; + +type Props = { + extensions: PluginExtensionLink[]; + onSelect: (extension: PluginExtensionLink) => void; +}; + +export function ToolbarExtensionPointMenu({ extensions, onSelect }: Props): ReactElement | null { + const { categorised, uncategorised } = useExtensionLinksByCategory(extensions); + const showDivider = uncategorised.length > 0 && Object.keys(categorised).length > 0; + + return ( + + <> + {Object.keys(categorised).map((category) => ( + + {renderItems(categorised[category], onSelect)} + + ))} + {showDivider && } + {renderItems(uncategorised, onSelect)} + + + ); +} + +function renderItems(extensions: PluginExtensionLink[], onSelect: (link: PluginExtensionLink) => void): JSX.Element[] { + return extensions.map((extension) => ( + { + if (extension.path) { + return onSelect(extension); + } + extension.onClick?.(event); + }} + /> + )); +} + +type ExtensionLinksResult = { + uncategorised: PluginExtensionLink[]; + categorised: Record; +}; + +function useExtensionLinksByCategory(extensions: PluginExtensionLink[]): ExtensionLinksResult { + return useMemo(() => { + const uncategorised: PluginExtensionLink[] = []; + const categorised: Record = {}; + + for (const link of extensions) { + if (!link.category) { + uncategorised.push(link); + continue; + } + + if (!Array.isArray(categorised[link.category])) { + categorised[link.category] = []; + } + categorised[link.category].push(link); + continue; + } + + return { + uncategorised, + categorised, + }; + }, [extensions]); +} diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx index 959194fea6d..402f80bdf83 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx @@ -15,12 +15,13 @@ describe('getExploreExtensionConfigs', () => { expect(extensions).toEqual([ { type: 'link', - title: 'Dashboard', + title: 'Add to dashboard', description: 'Use the query and panel from explore and create/add it to a dashboard', extensionPointId: PluginExtensionPoints.ExploreToolbarAction, icon: 'apps', configure: expect.any(Function), onClick: expect.any(Function), + category: 'Dashboards', }, ]); }); diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx index a325e63a6eb..7f95f4e714f 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx @@ -14,10 +14,11 @@ export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] { try { return [ createExtensionLinkConfig({ - title: 'Dashboard', + title: 'Add to dashboard', description: 'Use the query and panel from explore and create/add it to a dashboard', extensionPointId: PluginExtensionPoints.ExploreToolbarAction, icon: 'apps', + category: 'Dashboards', configure: () => { const canAddPanelToDashboard = contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor) ||