From f1529997f2ef59bdecbab2eb5755a4f31ac0dc63 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Wed, 28 Jun 2023 15:42:41 +0200 Subject: [PATCH] Explore: Make toolbar action extendable by plugins (#65524) * Cleaned up solution and starting to make it work properly. * will disable add button if no queries available. * Changed so 'add to dashboard' is registered as an extension in explore. * moved utility function to utils * hides button if insufficent permissions. * Fixed ts issue. * cleaned up the code and change to using the 'getPluginLinkExtensions' * Added values to explore context. * truncating title in menu. * added tests to verify explore extension point. * fixed failing tests in explore. * made excludeModal optional. * removed temporary fix to force old button. * reverted generated files. * fixed according to feedback. * Update public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx Co-authored-by: Levente Balogh * Update public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx Co-authored-by: Levente Balogh * added tests suggested in reviews. * fixed failing tests after sync with main. * replaced exploreId type with stirng. * cleaned up code a bit more. --------- Co-authored-by: Levente Balogh --- .../src/types/pluginExtensions.ts | 7 + public/app/app.ts | 12 +- .../features/dashboard/utils/getPanelMenu.ts | 9 +- public/app/features/explore/Explore.test.tsx | 39 +++- .../app/features/explore/ExploreToolbar.tsx | 27 +-- .../AddToDashboard/AddToDashboardForm.tsx} | 141 +++++++------ .../AddToDashboard/addToDashboard.test.ts | 2 +- .../AddToDashboard/addToDashboard.ts | 0 .../getAddToDashboardTitle.test.ts | 40 ++++ .../AddToDashboard/getAddToDashboardTitle.ts | 17 ++ .../AddToDashboard/index.test.tsx | 2 +- .../{ => extensions}/AddToDashboard/index.tsx | 16 +- .../extensions/ConfirmNavigationModal.tsx | 39 ++++ .../extensions/ToolbarExtensionPoint.test.tsx | 190 ++++++++++++++++++ .../extensions/ToolbarExtensionPoint.tsx | 127 ++++++++++++ .../getExploreExtensionConfigs.test.tsx | 50 +++++ .../extensions/getExploreExtensionConfigs.tsx | 45 +++++ .../features/explore/spec/helper/setup.tsx | 3 + .../getCoreExtensionConfigurations.ts | 6 + .../extensions/getPluginExtensions.test.ts | 2 + .../plugins/extensions/getPluginExtensions.ts | 10 +- .../app/features/plugins/extensions/utils.tsx | 27 +++ 22 files changed, 701 insertions(+), 110 deletions(-) rename public/app/features/explore/{AddToDashboard/AddToDashboardModal.tsx => extensions/AddToDashboard/AddToDashboardForm.tsx} (64%) rename public/app/features/explore/{ => extensions}/AddToDashboard/addToDashboard.test.ts (98%) rename public/app/features/explore/{ => extensions}/AddToDashboard/addToDashboard.ts (100%) create mode 100644 public/app/features/explore/extensions/AddToDashboard/getAddToDashboardTitle.test.ts create mode 100644 public/app/features/explore/extensions/AddToDashboard/getAddToDashboardTitle.ts rename public/app/features/explore/{ => extensions}/AddToDashboard/index.test.tsx (99%) rename public/app/features/explore/{ => extensions}/AddToDashboard/index.tsx (52%) create mode 100644 public/app/features/explore/extensions/ConfirmNavigationModal.tsx create mode 100644 public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx create mode 100644 public/app/features/explore/extensions/ToolbarExtensionPoint.tsx create mode 100644 public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx create mode 100644 public/app/features/explore/extensions/getExploreExtensionConfigs.tsx create mode 100644 public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index c297e24ad69..93229833e16 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -4,6 +4,7 @@ import { DataQuery, DataSourceJsonData } from '@grafana/schema'; import { ScopedVars } from './ScopedVars'; import { DataSourcePluginMeta, DataSourceSettings } from './datasource'; +import { IconName } from './icon'; import { PanelData } from './panel'; import { RawTimeRange, TimeZone } from './time'; @@ -27,6 +28,7 @@ export type PluginExtensionLink = PluginExtensionBase & { type: PluginExtensionTypes.link; path?: string; onClick?: (event?: React.MouseEvent) => void; + icon?: IconName; }; export type PluginExtensionComponent = PluginExtensionBase & { @@ -62,8 +64,12 @@ export type PluginExtensionLinkConfig = { description: string; path: string; onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + icon: IconName; }> | undefined; + + // (Optional) A icon that can be displayed in the ui for the extension option. + icon?: IconName; }; export type PluginExtensionComponentConfig = { @@ -100,6 +106,7 @@ export type PluginExtensionEventHelpers = { export enum PluginExtensionPoints { DashboardPanelMenu = 'grafana/dashboard/panel/menu', DataSourceConfig = 'grafana/datasources/config', + ExploreToolbarAction = 'grafana/explore/toolbar/action', } export type PluginExtensionPanelContext = { diff --git a/public/app/app.ts b/public/app/app.ts index ac3f2756be8..2189274260b 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -75,6 +75,7 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { DatasourceSrv } from './features/plugins/datasource_srv'; import { createPluginExtensionRegistry } from './features/plugins/extensions/createPluginExtensionRegistry'; +import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations'; import { getPluginExtensions } from './features/plugins/extensions/getPluginExtensions'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; import { preloadPlugins } from './features/plugins/pluginPreloader'; @@ -192,9 +193,16 @@ export class GrafanaApp { // Preload selected app plugins const preloadResults = await preloadPlugins(config.apps); - // Create extension registry out of the preloaded plugins + // Create extension registry out of preloaded plugins and core extensions + const extensionRegistry = createPluginExtensionRegistry([ + { pluginId: 'grafana', extensionConfigs: getCoreExtensionConfigurations() }, + ...preloadResults, + ]); + + // Expose the getPluginExtension function via grafana-runtime const pluginExtensionGetter: GetPluginExtensions = (options) => - getPluginExtensions({ ...options, registry: createPluginExtensionRegistry(preloadResults) }); + getPluginExtensions({ ...options, registry: extensionRegistry }); + setPluginExtensionGetter(pluginExtensionGetter); // initialize chrome service diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index 344001f07ae..ac58b5b3b85 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -25,6 +25,7 @@ import { } from 'app/features/dashboard/utils/panel'; import { InspectTab } from 'app/features/inspector/types'; import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard'; +import { truncateTitle } from 'app/features/plugins/extensions/utils'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { store } from 'app/store/store'; @@ -326,14 +327,6 @@ export function getPanelMenu( return menu; } -function truncateTitle(title: string, length: number): string { - if (title.length < length) { - return title; - } - const part = title.slice(0, length - 3); - return `${part.trimEnd()}...`; -} - function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): PluginExtensionPanelContext { return { id: panel.id, diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index bbeb209db17..0edebb7b230 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -3,8 +3,9 @@ import React from 'react'; import { AutoSizerProps } from 'react-virtualized-auto-sizer'; import { TestProvider } from 'test/helpers/TestProvider'; -import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data'; +import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv, PluginExtensionTypes } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { getPluginLinkExtensions } from '@grafana/runtime'; import { configureStore } from 'app/store/configureStore'; import { Explore, Props } from './Explore'; @@ -112,11 +113,18 @@ jest.mock('app/core/core', () => ({ }, })); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getPluginLinkExtensions: jest.fn(() => ({ extensions: [] })), +})); + // for the AutoSizer component to have a width jest.mock('react-virtualized-auto-sizer', () => { return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 }); }); +const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); + const setup = (overrideProps?: Partial) => { const store = configureStore({ explore: { @@ -154,6 +162,35 @@ describe('Explore', () => { expect(screen.getByTestId('explore-no-data')).toBeInTheDocument(); }); + it('should render toolbar extension point if extensions is available', async () => { + getPluginLinkExtensionsMock.mockReturnValueOnce({ + extensions: [ + { + id: '1', + pluginId: 'grafana', + title: 'Test 1', + description: '', + type: PluginExtensionTypes.link, + onClick: () => {}, + }, + { + id: '2', + pluginId: 'grafana', + title: 'Test 2', + description: '', + type: PluginExtensionTypes.link, + onClick: () => {}, + }, + ], + }); + + setup({ queryResponse: makeEmptyQueryResponse(LoadingState.Done) }); + // Wait for the Explore component to render + await screen.findByTestId(selectors.components.DataSourcePicker.container); + + expect(screen.getByRole('button', { name: 'Add' })).toBeVisible(); + }); + describe('On small screens', () => { const windowWidth = global.innerWidth, windowHeight = global.innerHeight; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 0fab403a8d0..39eb12347fd 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -1,16 +1,14 @@ import { css, cx } from '@emotion/css'; import { pick } from 'lodash'; -import React, { lazy, RefObject, Suspense, useMemo } from 'react'; +import React, { RefObject, useMemo } from 'react'; import { shallowEqual } from 'react-redux'; import { DataSourceInstanceSettings, RawTimeRange } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { defaultIntervals, PageToolbar, RefreshPicker, SetInterval, ToolbarButton, ButtonGroup } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; -import { contextSrv } from 'app/core/core'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; -import { AccessControlAction } from 'app/types'; import { StoreState, useDispatch, useSelector } from 'app/types/store'; import { DashNavButton } from '../dashboard/components/DashNav/DashNavButton'; @@ -20,6 +18,7 @@ import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors import { ExploreTimeControls } from './ExploreTimeControls'; import { LiveTailButton } from './LiveTailButton'; +import { ToolbarExtensionPoint } from './extensions/ToolbarExtensionPoint'; import { changeDatasource } from './state/datasource'; import { splitClose, splitOpen, maximizePaneAction, evenPaneResizeAction } from './state/main'; import { cancelQueries, runQueries, selectIsWaitingForData } from './state/query'; @@ -27,10 +26,6 @@ import { isSplit, selectPanesEntries } from './state/selectors'; import { syncTimes, changeRefreshInterval } from './state/time'; import { LiveTailControls } from './useLiveTailControls'; -const AddToDashboard = lazy(() => - import('./AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard })) -); - const rotateIcon = css({ '> div > svg': { transform: 'rotate(180deg)', @@ -118,13 +113,6 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props) dispatch(changeRefreshInterval({ exploreId, refreshInterval })); }; - const showExploreToDashboard = useMemo( - () => - contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor) || - contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor), - [] - ); - return (
{refreshInterval && } @@ -183,11 +171,12 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props) ), - showExploreToDashboard && ( - - - - ), + , !isLive && ( { +export function AddToDashboardForm(props: Props): ReactElement { + const { exploreId, onClose } = props; const exploreItem = useSelector(getExploreItemSelector(exploreId))!; const [submissionError, setSubmissionError] = useState(); const { @@ -91,8 +92,6 @@ export const AddToDashboardModal = ({ onClose, exploreId }: Props) => { const saveTarget = saveTargets.length > 1 ? watch('saveTarget') : saveTargets[0].value; - const modalTitle = `Add panel to ${saveTargets.length > 1 ? 'dashboard' : saveTargets[0].label!.toLowerCase()}`; - const onSubmit = async (openInNewTab: boolean, data: FormDTO) => { setSubmissionError(undefined); const dashboardUid = data.saveTarget === SaveTarget.ExistingDashboard ? data.dashboardUid : undefined; @@ -148,71 +147,69 @@ export const AddToDashboardModal = ({ onClose, exploreId }: Props) => { }, []); return ( - -
- {saveTargets.length > 1 && ( - ( - - - - )} - name="saveTarget" - /> - )} - - {saveTarget === SaveTarget.ExistingDashboard && - (() => { - assertIsSaveToExistingDashboardError(errors); - return ( - ( - - onChange(d?.uid)} - /> - - )} - control={control} - name="dashboardUid" - shouldUnregister - rules={{ required: { value: true, message: 'This field is required.' } }} - /> - ); - })()} - - {submissionError && ( - - {submissionError.message} - - )} - - - - - - - -
+
+ {saveTargets.length > 1 && ( + ( + + + + )} + name="saveTarget" + /> + )} + + {saveTarget === SaveTarget.ExistingDashboard && + (() => { + assertIsSaveToExistingDashboardError(errors); + return ( + ( + + onChange(d?.uid)} + /> + + )} + control={control} + name="dashboardUid" + shouldUnregister + rules={{ required: { value: true, message: 'This field is required.' } }} + /> + ); + })()} + + {submissionError && ( + + {submissionError.message} + + )} + + + + + + + ); -}; +} diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.test.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts similarity index 98% rename from public/app/features/explore/AddToDashboard/addToDashboard.test.ts rename to public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts index 299d5c8f183..ac300962f4a 100644 --- a/public/app/features/explore/AddToDashboard/addToDashboard.test.ts +++ b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts @@ -4,7 +4,7 @@ import { backendSrv } from 'app/core/services/backend_srv'; import * as api from 'app/features/dashboard/state/initDashboard'; import { ExplorePanelData } from 'app/types'; -import { createEmptyQueryResponse } from '../state/utils'; +import { createEmptyQueryResponse } from '../../state/utils'; import { setDashboardInLocalStorage } from './addToDashboard'; diff --git a/public/app/features/explore/AddToDashboard/addToDashboard.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts similarity index 100% rename from public/app/features/explore/AddToDashboard/addToDashboard.ts rename to public/app/features/explore/extensions/AddToDashboard/addToDashboard.ts diff --git a/public/app/features/explore/extensions/AddToDashboard/getAddToDashboardTitle.test.ts b/public/app/features/explore/extensions/AddToDashboard/getAddToDashboardTitle.test.ts new file mode 100644 index 00000000000..9485138e1d5 --- /dev/null +++ b/public/app/features/explore/extensions/AddToDashboard/getAddToDashboardTitle.test.ts @@ -0,0 +1,40 @@ +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { getAddToDashboardTitle } from './getAddToDashboardTitle'; + +jest.mock('app/core/services/context_srv'); + +const contextSrvMock = jest.mocked(contextSrv); + +describe('getAddToDashboardTitle', () => { + beforeEach(() => contextSrvMock.hasAccess.mockReset()); + + it('should return title ending with "dashboard" if user has full access', () => { + contextSrvMock.hasAccess.mockReturnValue(true); + + expect(getAddToDashboardTitle()).toBe('Add panel to dashboard'); + }); + + it('should return title ending with "dashboard" if user has no access', () => { + contextSrvMock.hasAccess.mockReturnValue(false); + + expect(getAddToDashboardTitle()).toBe('Add panel to dashboard'); + }); + + it('should return title ending with "new dashboard" if user only has access to create dashboards', () => { + contextSrvMock.hasAccess.mockImplementation((action) => { + return action === AccessControlAction.DashboardsCreate; + }); + + expect(getAddToDashboardTitle()).toBe('Add panel to new dashboard'); + }); + + it('should return title ending with "existing dashboard" if user only has access to edit dashboards', () => { + contextSrvMock.hasAccess.mockImplementation((action) => { + return action === AccessControlAction.DashboardsWrite; + }); + + expect(getAddToDashboardTitle()).toBe('Add panel to existing dashboard'); + }); +}); diff --git a/public/app/features/explore/extensions/AddToDashboard/getAddToDashboardTitle.ts b/public/app/features/explore/extensions/AddToDashboard/getAddToDashboardTitle.ts new file mode 100644 index 00000000000..65c03793e30 --- /dev/null +++ b/public/app/features/explore/extensions/AddToDashboard/getAddToDashboardTitle.ts @@ -0,0 +1,17 @@ +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types'; + +export function getAddToDashboardTitle(): string { + const canCreateDashboard = contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor); + const canWriteDashboard = contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor); + + if (canCreateDashboard && !canWriteDashboard) { + return 'Add panel to new dashboard'; + } + + if (canWriteDashboard && !canCreateDashboard) { + return 'Add panel to existing dashboard'; + } + + return 'Add panel to dashboard'; +} diff --git a/public/app/features/explore/AddToDashboard/index.test.tsx b/public/app/features/explore/extensions/AddToDashboard/index.test.tsx similarity index 99% rename from public/app/features/explore/AddToDashboard/index.test.tsx rename to public/app/features/explore/extensions/AddToDashboard/index.test.tsx index 0ed31bc9380..7067186dfe4 100644 --- a/public/app/features/explore/AddToDashboard/index.test.tsx +++ b/public/app/features/explore/extensions/AddToDashboard/index.test.tsx @@ -13,7 +13,7 @@ import { DashboardSearchItemType } from 'app/features/search/types'; import { configureStore } from 'app/store/configureStore'; import { ExploreState } from 'app/types'; -import { createEmptyQueryResponse } from '../state/utils'; +import { createEmptyQueryResponse } from '../../state/utils'; import * as api from './addToDashboard'; diff --git a/public/app/features/explore/AddToDashboard/index.tsx b/public/app/features/explore/extensions/AddToDashboard/index.tsx similarity index 52% rename from public/app/features/explore/AddToDashboard/index.tsx rename to public/app/features/explore/extensions/AddToDashboard/index.tsx index 3fe16b6a190..816d4a0c895 100644 --- a/public/app/features/explore/AddToDashboard/index.tsx +++ b/public/app/features/explore/extensions/AddToDashboard/index.tsx @@ -1,11 +1,12 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; -import { ToolbarButton } from '@grafana/ui'; +import { Modal, ToolbarButton } from '@grafana/ui'; import { useSelector } from 'app/types'; -import { getExploreItemSelector } from '../state/selectors'; +import { getExploreItemSelector } from '../../state/selectors'; -import { AddToDashboardModal } from './AddToDashboardModal'; +import { AddToDashboardForm } from './AddToDashboardForm'; +import { getAddToDashboardTitle } from './getAddToDashboardTitle'; interface Props { exploreId: string; @@ -15,6 +16,7 @@ export const AddToDashboard = ({ exploreId }: Props) => { const [isOpen, setIsOpen] = useState(false); const selectExploreItem = getExploreItemSelector(exploreId); const explorePaneHasQueries = !!useSelector(selectExploreItem)?.queries?.length; + const onClose = useCallback(() => setIsOpen(false), []); return ( <> @@ -28,7 +30,11 @@ export const AddToDashboard = ({ exploreId }: Props) => { Add to dashboard - {isOpen && setIsOpen(false)} exploreId={exploreId} />} + {isOpen && ( + + + + )} ); }; diff --git a/public/app/features/explore/extensions/ConfirmNavigationModal.tsx b/public/app/features/explore/extensions/ConfirmNavigationModal.tsx new file mode 100644 index 00000000000..b9481582e80 --- /dev/null +++ b/public/app/features/explore/extensions/ConfirmNavigationModal.tsx @@ -0,0 +1,39 @@ +import React, { ReactElement } from 'react'; + +import { locationUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { Button, Modal, VerticalGroup } from '@grafana/ui'; + +type Props = { + onDismiss: () => void; + path: string; + title: string; +}; + +export function ConfirmNavigationModal(props: Props): ReactElement { + const { onDismiss, path, title } = props; + const openInNewTab = () => { + global.open(locationUtil.assureBaseUrl(path), '_blank'); + onDismiss(); + }; + const openInCurrentTab = () => locationService.push(path); + + return ( + + +

Do you want to proceed in the current tab or open a new tab?

+
+ + + + + +
+ ); +} diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx new file mode 100644 index 00000000000..cce2e4012f5 --- /dev/null +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx @@ -0,0 +1,190 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ReactNode } from 'react'; +import { Provider } from 'react-redux'; + +import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data'; +import { getPluginLinkExtensions } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; +import { contextSrv } from 'app/core/services/context_srv'; +import { configureStore } from 'app/store/configureStore'; +import { ExplorePanelData, ExploreState } from 'app/types'; + +import { createEmptyQueryResponse } from '../state/utils'; + +import { ToolbarExtensionPoint } from './ToolbarExtensionPoint'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getPluginLinkExtensions: jest.fn(), +})); + +jest.mock('app/core/services/context_srv'); + +const contextSrvMock = jest.mocked(contextSrv); +const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); + +type storeOptions = { + targets: DataQuery[]; + data: ExplorePanelData; +}; + +function renderWithExploreStore( + children: ReactNode, + options: storeOptions = { targets: [{ refId: 'A' }], data: createEmptyQueryResponse() } +) { + const { targets, data } = options; + const store = configureStore({ + explore: { + panes: { + left: { + queries: targets, + queryResponse: data, + range: { + raw: { from: 'now-1h', to: 'now' }, + }, + }, + }, + } as unknown as ExploreState, + }); + + render({children}, {}); +} + +describe('ToolbarExtensionPoint', () => { + describe('with extension points', () => { + 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' })); + + 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' })); + + expect(screen.getByRole('menuitem', { name: 'Dashboard' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible(); + }); + + it('should call onClick from extension when menu item is clicked', async () => { + renderWithExploreStore(); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.click(screen.getByRole('menuitem', { name: 'Dashboard' })); + + const { extensions } = getPluginLinkExtensions({ extensionPointId: PluginExtensionPoints.ExploreToolbarAction }); + const [extension] = extensions; + + expect(jest.mocked(extension.onClick)).toBeCalledTimes(1); + }); + + it('should render confirm navigation modal when extension with path is clicked', async () => { + renderWithExploreStore(); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.click(screen.getByRole('menuitem', { name: 'ML: Forecast' })); + + expect(screen.getByRole('button', { name: 'Open in new tab' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Open' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeVisible(); + }); + + it('should pass a correct constructed context when fetching extensions', async () => { + const targets = [{ refId: 'A' }]; + const data = createEmptyQueryResponse(); + + renderWithExploreStore(, { + targets, + data, + }); + + const [options] = getPluginLinkExtensionsMock.mock.calls[0]; + const { context } = options; + + expect(context).toEqual({ + exploreId: 'left', + targets, + data: expect.objectContaining({ + ...data, + timeRange: expect.any(Object), + }), + timeZone: 'browser', + timeRange: { from: 'now-1h', to: 'now' }, + }); + }); + + it('should correct extension point id when fetching extensions', async () => { + renderWithExploreStore(); + + const [options] = getPluginLinkExtensionsMock.mock.calls[0]; + const { extensionPointId } = options; + + expect(extensionPointId).toBe(PluginExtensionPoints.ExploreToolbarAction); + }); + }); + + describe('without extension points', () => { + beforeAll(() => { + contextSrvMock.hasAccess.mockReturnValue(true); + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); + }); + + it('should render "add to dashboard" action button if one pane is visible', async () => { + renderWithExploreStore(); + + await waitFor(() => { + const button = screen.getByRole('button', { name: /add to dashboard/i }); + + expect(button).toBeVisible(); + expect(button).toBeEnabled(); + }); + }); + }); + + describe('with insufficient permissions', () => { + beforeAll(() => { + contextSrvMock.hasAccess.mockReturnValue(false); + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); + }); + + it('should not render "add to dashboard" action button', async () => { + renderWithExploreStore(); + + expect(screen.queryByRole('button', { name: /add to dashboard/i })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx new file mode 100644 index 00000000000..f360f839a39 --- /dev/null +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -0,0 +1,127 @@ +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 { 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'; + +const AddToDashboard = lazy(() => + import('./AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard })) +); + +type Props = { + exploreId: string; + timeZone: TimeZone; + splitted: boolean; +}; + +export function ToolbarExtensionPoint(props: Props): ReactElement | null { + const { exploreId, splitted } = props; + const [selectedExtension, setSelectedExtension] = useState(); + const [isOpen, setIsOpen] = useState(false); + const context = useExtensionPointContext(props); + const extensions = useExtensionLinks(context); + const selectExploreItem = getExploreItemSelector(exploreId); + const noQueriesInPane = 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 (extensions.length <= 1) { + const canAddPanelToDashboard = + contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor) || + contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor); + + if (!canAddPanelToDashboard) { + return null; + } + + return ( + + + + ); + } + + const menu = ( + + {extensions.map((extension) => ( + { + if (extension.path) { + return setSelectedExtension(extension); + } + extension.onClick?.(event); + }} + /> + ))} + + ); + + return ( + <> + + + {splitted ? ' ' : 'Add'} + + + {!!selectedExtension && !!selectedExtension.path && ( + setSelectedExtension(undefined)} + /> + )} + + ); +} + +export type PluginExtensionExploreContext = { + exploreId: string; + targets: DataQuery[]; + data: ExplorePanelData; + timeRange: RawTimeRange; + timeZone: TimeZone; +}; + +function useExtensionPointContext(props: Props): PluginExtensionExploreContext { + const { exploreId, timeZone } = props; + const { queries, queryResponse, range } = useSelector(getExploreItemSelector(exploreId))!; + + return useMemo(() => { + return { + exploreId, + targets: queries, + data: queryResponse, + timeRange: range.raw, + timeZone: timeZone, + }; + }, [exploreId, queries, queryResponse, range, timeZone]); +} + +function useExtensionLinks(context: PluginExtensionExploreContext): PluginExtensionLink[] { + return useMemo(() => { + const { extensions } = getPluginLinkExtensions({ + extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + context: context, + }); + + return extensions; + }, [context]); +} diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx new file mode 100644 index 00000000000..959194fea6d --- /dev/null +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx @@ -0,0 +1,50 @@ +import { PluginExtensionPoints } from '@grafana/data'; +import { contextSrv } from 'app/core/services/context_srv'; + +import { getExploreExtensionConfigs } from './getExploreExtensionConfigs'; + +jest.mock('app/core/services/context_srv'); + +const contextSrvMock = jest.mocked(contextSrv); + +describe('getExploreExtensionConfigs', () => { + describe('configured items returned', () => { + it('should return array with core extensions added in explore', () => { + const extensions = getExploreExtensionConfigs(); + + expect(extensions).toEqual([ + { + type: 'link', + title: '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), + }, + ]); + }); + }); + + describe('configure function for "add to dashboard" extension', () => { + afterEach(() => contextSrvMock.hasAccess.mockRestore()); + + it('should return undefined if insufficient permissions', () => { + contextSrvMock.hasAccess.mockReturnValue(false); + + const extensions = getExploreExtensionConfigs(); + const [extension] = extensions; + + expect(extension?.configure?.()).toBeUndefined(); + }); + + it('should return empty object if sufficient permissions', () => { + contextSrvMock.hasAccess.mockReturnValue(true); + + const extensions = getExploreExtensionConfigs(); + const [extension] = extensions; + + expect(extension?.configure?.()).toEqual({}); + }); + }); +}); diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx new file mode 100644 index 00000000000..a325e63a6eb --- /dev/null +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { PluginExtensionPoints, type PluginExtensionLinkConfig } from '@grafana/data'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; + +import { createExtensionLinkConfig, logWarning } from '../../plugins/extensions/utils'; + +import { AddToDashboardForm } from './AddToDashboard/AddToDashboardForm'; +import { getAddToDashboardTitle } from './AddToDashboard/getAddToDashboardTitle'; +import { type PluginExtensionExploreContext } from './ToolbarExtensionPoint'; + +export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] { + try { + return [ + createExtensionLinkConfig({ + title: 'Dashboard', + description: 'Use the query and panel from explore and create/add it to a dashboard', + extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + icon: 'apps', + configure: () => { + const canAddPanelToDashboard = + contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor) || + contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor); + + // hide option if user has insufficient permissions + if (!canAddPanelToDashboard) { + return undefined; + } + + return {}; + }, + onClick: (_, { context, openModal }) => { + openModal({ + title: getAddToDashboardTitle(), + body: ({ onDismiss }) => , + }); + }, + }), + ]; + } catch (error) { + logWarning(`Could not configure extensions for Explore due to: "${error}"`); + return []; + } +} diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx index 4397d0c28c4..4bce74e55b8 100644 --- a/public/app/features/explore/spec/helper/setup.tsx +++ b/public/app/features/explore/spec/helper/setup.tsx @@ -22,6 +22,7 @@ import { setLocationService, HistoryWrapper, LocationService, + setPluginExtensionGetter, } from '@grafana/runtime'; import { DataSourceRef } from '@grafana/schema'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; @@ -54,6 +55,8 @@ export function setupExplore(options?: SetupOptions): { container: HTMLElement; location: LocationService; } { + setPluginExtensionGetter(() => ({ extensions: [] })); + // Clear this up otherwise it persists data source selection // TODO: probably add test for that too if (options?.clearLocalStorage !== false) { diff --git a/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts b/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts new file mode 100644 index 00000000000..9d4578febf0 --- /dev/null +++ b/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts @@ -0,0 +1,6 @@ +import { type PluginExtensionLinkConfig } from '@grafana/data'; +import { getExploreExtensionConfigs } from 'app/features/explore/extensions/getExploreExtensionConfigs'; + +export function getCoreExtensionConfigurations(): PluginExtensionLinkConfig[] { + return [...getExploreExtensionConfigs()]; +} diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.ts b/public/app/features/plugins/extensions/getPluginExtensions.test.ts index 32720dffbf9..1b921dae87e 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.ts @@ -116,6 +116,7 @@ describe('getPluginExtensions()', () => { title: 'Updated title', description: 'Updated description', path: `/a/${pluginId}/updated-path`, + icon: 'search', })); const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); @@ -128,6 +129,7 @@ describe('getPluginExtensions()', () => { expect(extension.title).toBe('Updated title'); expect(extension.description).toBe('Updated description'); expect(extension.path).toBe(`/a/${pluginId}/updated-path`); + expect(extension.icon).toBe('search'); }); test('should hide the extension if it tries to override not-allowed properties with the configure() function', () => { diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 4c1c38d3c36..8cccc936252 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -74,6 +74,7 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, onClick: getLinkExtensionOnClick(extensionConfig, frozenContext), // Configurable properties + icon: overrides?.icon || extensionConfig.icon, title: overrides?.title || extensionConfig.title, description: overrides?.description || extensionConfig.description, path: overrides?.path || extensionConfig.path, @@ -119,7 +120,13 @@ function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLink return undefined; } - let { title = config.title, description = config.description, path = config.path, ...rest } = overrides; + let { + title = config.title, + description = config.description, + path = config.path, + icon = config.icon, + ...rest + } = overrides; assertIsNotPromise( overrides, @@ -141,6 +148,7 @@ function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLink title, description, path, + icon, }; } catch (error) { if (error instanceof Error) { diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index aa80f588912..96d6dd50a46 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -166,3 +166,30 @@ function isRecord(value: unknown): value is Record( + config: Omit, 'type'> +): PluginExtensionLinkConfig { + const linkConfig: PluginExtensionLinkConfig = { + type: PluginExtensionTypes.link, + ...config, + }; + assertLinkConfig(linkConfig); + return linkConfig; +} + +function assertLinkConfig( + config: PluginExtensionLinkConfig +): asserts config is PluginExtensionLinkConfig { + if (config.type !== PluginExtensionTypes.link) { + throw Error('config is not a extension link'); + } +} + +export function truncateTitle(title: string, length: number): string { + if (title.length < length) { + return title; + } + const part = title.slice(0, length - 3); + return `${part.trimEnd()}...`; +}