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>
This commit is contained in:
Marcus Andersson 2023-06-28 15:42:41 +02:00 committed by GitHub
parent 05b997f3d9
commit f1529997f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 698 additions and 107 deletions

View File

@ -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<Context extends object = object> = {
description: string;
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
icon: IconName;
}>
| undefined;
// (Optional) A icon that can be displayed in the ui for the extension option.
icon?: IconName;
};
export type PluginExtensionComponentConfig<Context extends object = object> = {
@ -100,6 +106,7 @@ export type PluginExtensionEventHelpers<Context extends object = object> = {
export enum PluginExtensionPoints {
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
DataSourceConfig = 'grafana/datasources/config',
ExploreToolbarAction = 'grafana/explore/toolbar/action',
}
export type PluginExtensionPanelContext = {

View File

@ -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

View File

@ -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,

View File

@ -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<Props>) => {
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;

View File

@ -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 (
<div ref={topOfViewRef}>
{refreshInterval && <SetInterval func={onRunQuery} interval={refreshInterval} loading={loading} />}
@ -183,11 +171,12 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
</ToolbarButton>
</ButtonGroup>
),
showExploreToDashboard && (
<Suspense key="addToDashboard" fallback={null}>
<AddToDashboard exploreId={exploreId} />
</Suspense>
),
<ToolbarExtensionPoint
splitted={splitted}
key="toolbar-extension-point"
exploreId={exploreId}
timeZone={timeZone}
/>,
!isLive && (
<ExploreTimeControls
key="timeControls"

View File

@ -1,5 +1,5 @@
import { partial } from 'lodash';
import React, { useEffect, useState } from 'react';
import React, { type ReactElement, useEffect, useState } from 'react';
import { DeepMap, FieldError, useForm } from 'react-hook-form';
import { locationUtil, SelectableValue } from '@grafana/data';
@ -10,7 +10,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { removeDashboardToFetchFromLocalStorage } from 'app/features/dashboard/state/initDashboard';
import { AccessControlAction, useSelector } from 'app/types';
import { getExploreItemSelector } from '../state/selectors';
import { getExploreItemSelector } from '../../state/selectors';
import { setDashboardInLocalStorage, AddToDashboardError } from './addToDashboard';
@ -60,7 +60,8 @@ interface Props {
exploreId: string;
}
export const AddToDashboardModal = ({ onClose, exploreId }: Props) => {
export function AddToDashboardForm(props: Props): ReactElement {
const { exploreId, onClose } = props;
const exploreItem = useSelector(getExploreItemSelector(exploreId))!;
const [submissionError, setSubmissionError] = useState<SubmissionError | undefined>();
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 (
<Modal title={modalTitle} onDismiss={onClose} isOpen>
<form>
{saveTargets.length > 1 && (
<InputControl
control={control}
render={({ field: { ref, ...field } }) => (
<Field label="Target dashboard" description="Choose where to add the panel.">
<RadioButtonGroup options={saveTargets} {...field} id="e2d-save-target" />
</Field>
)}
name="saveTarget"
/>
)}
<form>
{saveTargets.length > 1 && (
<InputControl
control={control}
render={({ field: { ref, ...field } }) => (
<Field label="Target dashboard" description="Choose where to add the panel.">
<RadioButtonGroup options={saveTargets} {...field} id="e2d-save-target" />
</Field>
)}
name="saveTarget"
/>
)}
{saveTarget === SaveTarget.ExistingDashboard &&
(() => {
assertIsSaveToExistingDashboardError(errors);
return (
<InputControl
render={({ field: { ref, value, onChange, ...field } }) => (
<Field
label="Dashboard"
description="Select in which dashboard the panel will be created."
error={errors.dashboardUid?.message}
invalid={!!errors.dashboardUid}
>
<DashboardPicker
{...field}
inputId="e2d-dashboard-picker"
defaultOptions
onChange={(d) => onChange(d?.uid)}
/>
</Field>
)}
control={control}
name="dashboardUid"
shouldUnregister
rules={{ required: { value: true, message: 'This field is required.' } }}
/>
);
})()}
{saveTarget === SaveTarget.ExistingDashboard &&
(() => {
assertIsSaveToExistingDashboardError(errors);
return (
<InputControl
render={({ field: { ref, value, onChange, ...field } }) => (
<Field
label="Dashboard"
description="Select in which dashboard the panel will be created."
error={errors.dashboardUid?.message}
invalid={!!errors.dashboardUid}
>
<DashboardPicker
{...field}
inputId="e2d-dashboard-picker"
defaultOptions
onChange={(d) => onChange(d?.uid)}
/>
</Field>
)}
control={control}
name="dashboardUid"
shouldUnregister
rules={{ required: { value: true, message: 'This field is required.' } }}
/>
);
})()}
{submissionError && (
<Alert severity="error" title="Error adding the panel">
{submissionError.message}
</Alert>
)}
{submissionError && (
<Alert severity="error" title="Error adding the panel">
{submissionError.message}
</Alert>
)}
<Modal.ButtonRow>
<Button type="reset" onClick={onClose} fill="outline" variant="secondary">
Cancel
</Button>
<Button
type="submit"
variant="secondary"
onClick={handleSubmit(partial(onSubmit, true))}
icon="external-link-alt"
>
Open in new tab
</Button>
<Button type="submit" variant="primary" onClick={handleSubmit(partial(onSubmit, false))} icon="apps">
Open dashboard
</Button>
</Modal.ButtonRow>
</form>
</Modal>
<Modal.ButtonRow>
<Button type="reset" onClick={onClose} fill="outline" variant="secondary">
Cancel
</Button>
<Button
type="submit"
variant="secondary"
onClick={handleSubmit(partial(onSubmit, true))}
icon="external-link-alt"
>
Open in new tab
</Button>
<Button type="submit" variant="primary" onClick={handleSubmit(partial(onSubmit, false))} icon="apps">
Open dashboard
</Button>
</Modal.ButtonRow>
</form>
);
};
}

View File

@ -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';

View File

@ -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');
});
});

View File

@ -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';
}

View File

@ -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';

View File

@ -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
</ToolbarButton>
{isOpen && <AddToDashboardModal onClose={() => setIsOpen(false)} exploreId={exploreId} />}
{isOpen && (
<Modal title={getAddToDashboardTitle()} onDismiss={onClose} isOpen>
<AddToDashboardForm onClose={onClose} exploreId={exploreId} />
</Modal>
)}
</>
);
};

View File

@ -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>
);
}

View File

@ -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();
});
});
});

View File

@ -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]);
}

View File

@ -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({});
});
});
});

View File

@ -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 [];
}
}

View File

@ -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) {

View File

@ -0,0 +1,6 @@
import { type PluginExtensionLinkConfig } from '@grafana/data';
import { getExploreExtensionConfigs } from 'app/features/explore/extensions/getExploreExtensionConfigs';
export function getCoreExtensionConfigurations(): PluginExtensionLinkConfig[] {
return [...getExploreExtensionConfigs()];
}

View File

@ -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', () => {

View File

@ -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) {

View File

@ -166,3 +166,30 @@ function isRecord(value: unknown): value is Record<string | number | symbol, unk
export function isReadOnlyProxy(value: unknown): boolean {
return isRecord(value) && value[_isProxy] === true;
}
export function createExtensionLinkConfig<T extends object>(
config: Omit<PluginExtensionLinkConfig<T>, 'type'>
): PluginExtensionLinkConfig {
const linkConfig: PluginExtensionLinkConfig<T> = {
type: PluginExtensionTypes.link,
...config,
};
assertLinkConfig(linkConfig);
return linkConfig;
}
function assertLinkConfig<T extends object>(
config: PluginExtensionLinkConfig<T>
): 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()}...`;
}