mirror of https://github.com/grafana/grafana
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 <balogh.levente.hu@gmail.com> * Update public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * 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 <balogh.levente.hu@gmail.com>pull/70855/head
parent
05b997f3d9
commit
f1529997f2
@ -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'); |
||||
}); |
||||
}); |
@ -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'; |
||||
} |
@ -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 ( |
||||
<Modal title={title} isOpen onDismiss={onDismiss}> |
||||
<VerticalGroup spacing="sm"> |
||||
<p>Do you want to proceed in the current tab or open a new tab?</p> |
||||
</VerticalGroup> |
||||
<Modal.ButtonRow> |
||||
<Button onClick={onDismiss} fill="outline" variant="secondary"> |
||||
Cancel |
||||
</Button> |
||||
<Button type="submit" variant="secondary" onClick={openInNewTab} icon="external-link-alt"> |
||||
Open in new tab |
||||
</Button> |
||||
<Button type="submit" variant="primary" onClick={openInCurrentTab} icon="apps"> |
||||
Open |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</Modal> |
||||
); |
||||
} |
@ -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(<Provider store={store}>{children}</Provider>, {}); |
||||
} |
||||
|
||||
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); |
||||
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible(); |
||||
}); |
||||
|
||||
it('should render "Add" extension point menu button in split mode', async () => { |
||||
renderWithExploreStore(<ToolbarExtensionPoint exploreId={'left'} timeZone="browser" splitted={true} />); |
||||
|
||||
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); |
||||
|
||||
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); |
||||
|
||||
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); |
||||
|
||||
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />, { |
||||
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); |
||||
|
||||
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); |
||||
|
||||
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" splitted={false} />); |
||||
|
||||
expect(screen.queryByRole('button', { name: /add to dashboard/i })).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
@ -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<PluginExtensionLink | undefined>(); |
||||
const [isOpen, setIsOpen] = useState<boolean>(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 ( |
||||
<Suspense fallback={null}> |
||||
<AddToDashboard exploreId={exploreId} /> |
||||
</Suspense> |
||||
); |
||||
} |
||||
|
||||
const menu = ( |
||||
<Menu> |
||||
{extensions.map((extension) => ( |
||||
<Menu.Item |
||||
ariaLabel={extension.title} |
||||
icon={extension?.icon || 'plug'} |
||||
key={extension.id} |
||||
label={truncateTitle(extension.title, 25)} |
||||
onClick={(event) => { |
||||
if (extension.path) { |
||||
return setSelectedExtension(extension); |
||||
} |
||||
extension.onClick?.(event); |
||||
}} |
||||
/> |
||||
))} |
||||
</Menu> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<Dropdown onVisibleChange={setIsOpen} placement="bottom-start" overlay={menu}> |
||||
<ToolbarButton |
||||
aria-label="Add" |
||||
icon="plus" |
||||
disabled={!Boolean(noQueriesInPane)} |
||||
variant="canvas" |
||||
isOpen={isOpen} |
||||
> |
||||
{splitted ? ' ' : 'Add'} |
||||
</ToolbarButton> |
||||
</Dropdown> |
||||
{!!selectedExtension && !!selectedExtension.path && ( |
||||
<ConfirmNavigationModal |
||||
path={selectedExtension.path} |
||||
title={selectedExtension.title} |
||||
onDismiss={() => 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]); |
||||
} |
@ -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({}); |
||||
}); |
||||
}); |
||||
}); |
@ -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<PluginExtensionExploreContext>({ |
||||
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 }) => <AddToDashboardForm onClose={onDismiss!} exploreId={context?.exploreId!} />, |
||||
}); |
||||
}, |
||||
}), |
||||
]; |
||||
} catch (error) { |
||||
logWarning(`Could not configure extensions for Explore due to: "${error}"`); |
||||
return []; |
||||
} |
||||
} |
@ -0,0 +1,6 @@ |
||||
import { type PluginExtensionLinkConfig } from '@grafana/data'; |
||||
import { getExploreExtensionConfigs } from 'app/features/explore/extensions/getExploreExtensionConfigs'; |
||||
|
||||
export function getCoreExtensionConfigurations(): PluginExtensionLinkConfig[] { |
||||
return [...getExploreExtensionConfigs()]; |
||||
} |
Loading…
Reference in new issue