mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
05b997f3d9
commit
f1529997f2
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
@ -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';
|
||||
|
@ -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';
|
||||
}
|
@ -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';
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
127
public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
Normal file
127
public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
Normal 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]);
|
||||
}
|
@ -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 [];
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { type PluginExtensionLinkConfig } from '@grafana/data';
|
||||
import { getExploreExtensionConfigs } from 'app/features/explore/extensions/getExploreExtensionConfigs';
|
||||
|
||||
export function getCoreExtensionConfigurations(): PluginExtensionLinkConfig[] {
|
||||
return [...getExploreExtensionConfigs()];
|
||||
}
|
@ -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', () => {
|
||||
|
@ -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) {
|
||||
|
@ -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()}...`;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user