diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx index 4553af1f4d8..dcd13745076 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx @@ -6,7 +6,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { PanelProps } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { config, locationService, setPluginImportUtils } from '@grafana/runtime'; +import { config, getPluginLinkExtensions, locationService, setPluginImportUtils } from '@grafana/runtime'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { setupLoadDashboardMock } from '../utils/test-utils'; @@ -15,6 +15,8 @@ import { DashboardScenePage, Props } from './DashboardScenePage'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), + setPluginExtensionGetter: jest.fn(), + getPluginLinkExtensions: jest.fn(), getDataSourceSrv: () => { return { get: jest.fn().mockResolvedValue({}), @@ -23,6 +25,8 @@ jest.mock('@grafana/runtime', () => ({ }, })); +const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); + function setup() { const context = getGrafanaContextMock(); const props: Props = { @@ -97,6 +101,8 @@ describe('DashboardScenePage', () => { // hacky way because mocking autosizer does not work Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 }); Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 }); + getPluginLinkExtensionsMock.mockRestore(); + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); }); it('Can render dashboard', async () => { @@ -123,6 +129,7 @@ describe('DashboardScenePage', () => { await userEvent.click(screen.getByLabelText('Menu for panel with title Panel B')); const inspectMenuItem = await screen.findAllByText('Inspect'); + act(() => fireEvent.click(inspectMenuItem[0])); expect(await screen.findByText('Inspect: Panel B')).toBeInTheDocument(); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 768463456f8..3056105701d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -35,6 +35,8 @@ import { setupKeyboardShortcuts } from './keyboardShortcuts'; export interface DashboardSceneState extends SceneObjectState { /** The title */ title: string; + /** Tags */ + tags?: string[]; /** A uid when saved */ uid?: string; /** @deprecated */ diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx index 53f3aa79bc9..4e5a40c5fdb 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx @@ -1,6 +1,24 @@ +import { + FieldType, + LoadingState, + PanelData, + PluginExtensionPanelContext, + PluginExtensionTypes, + getDefaultTimeRange, + toDataFrame, +} from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { locationService } from '@grafana/runtime'; -import { SceneGridItem, SceneGridLayout, SceneQueryRunner, VizPanel, VizPanelMenu } from '@grafana/scenes'; +import { getPluginLinkExtensions, locationService } from '@grafana/runtime'; +import { + LocalValueVariable, + SceneGridItem, + SceneGridLayout, + SceneQueryRunner, + SceneTimeRange, + SceneVariableSet, + VizPanel, + VizPanelMenu, +} from '@grafana/scenes'; import { contextSrv } from 'app/core/services/context_srv'; import { GetExploreUrlArguments } from 'app/core/utils/explore'; @@ -21,7 +39,20 @@ jest.mock('app/core/utils/explore', () => ({ jest.mock('app/core/services/context_srv'); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + setPluginExtensionGetter: jest.fn(), + getPluginLinkExtensions: jest.fn(), +})); + +const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); + describe('panelMenuBehavior', () => { + beforeEach(() => { + getPluginLinkExtensionsMock.mockRestore(); + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); + }); + beforeAll(() => { locationService.push('/scenes/dashboard/dash-1?from=now-5m&to=now'); }); @@ -51,7 +82,7 @@ describe('panelMenuBehavior', () => { // Verify explore url is called with correct arguments const getExploreArgs: GetExploreUrlArguments = mocks.getExploreUrl.mock.calls[0][0]; expect(getExploreArgs.dsRef).toEqual({ uid: 'my-uid' }); - expect(getExploreArgs.queries).toEqual([{ query: 'buu', refId: 'A' }]); + expect(getExploreArgs.queries).toEqual([{ query: 'QueryA', refId: 'A' }]); expect(getExploreArgs.scopedVars?.__sceneObject?.value).toBe(panel); // verify inspect url keeps url params and adds inspect= @@ -60,6 +91,384 @@ describe('panelMenuBehavior', () => { expect(menu.state.items?.[4].subMenu?.length).toBe(3); }); + + describe('when extending panel menu from plugins', () => { + it('should contain menu item from link extension', async () => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + }, + ], + }); + + const { menu, panel } = await buildTestScene({}); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.length).toBe(7); + + const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu; + + expect(extensionsSubMenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident', + href: '/a/grafana-basic-app/declare-incident', + }), + ]) + ); + }); + + it('should truncate menu item title to 25 chars', async () => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident when pressing this amazing menu item', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + }, + ], + }); + + const { menu, panel } = await buildTestScene({}); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.length).toBe(7); + + const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu; + + expect(extensionsSubMenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident when...', + href: '/a/grafana-basic-app/declare-incident', + }), + ]) + ); + }); + + it('should pass onClick from plugin extension link to menu item', async () => { + const expectedOnClick = jest.fn(); + + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident when pressing this amazing menu item', + description: 'Declaring an incident in the app', + onClick: expectedOnClick, + }, + ], + }); + + const { menu, panel } = await buildTestScene({}); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.length).toBe(7); + + const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu; + const menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...')); + + menuItem?.onClick?.({} as React.MouseEvent); + expect(expectedOnClick).toBeCalledTimes(1); + }); + + it('should pass context with correct values when configuring extension', async () => { + const data: PanelData = { + series: [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'score', type: FieldType.number }, + ], + }), + ], + timeRange: getDefaultTimeRange(), + state: LoadingState.Done, + }; + + const { menu, panel } = await buildTestScene({}); + + panel.state.$data?.setState({ data }); + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + const context: PluginExtensionPanelContext = { + id: 12, + pluginId: 'table', + title: 'Panel A', + timeZone: 'Africa/Abidjan', + timeRange: { + from: 'now-5m', + to: 'now', + }, + targets: [ + { + refId: 'A', + // @ts-expect-error + query: 'QueryA', + }, + ], + dashboard: { + tags: ['database', 'panel'], + uid: 'dash-1', + title: 'My dashboard', + }, + scopedVars: { + a: { + text: 'a', + value: 'a', + }, + }, + data, + }; + + expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context })); + }); + + it('should pass context with default time zone values when configuring extension', async () => { + const data: PanelData = { + series: [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'score', type: FieldType.number }, + ], + }), + ], + timeRange: getDefaultTimeRange(), + state: LoadingState.Done, + }; + + const { menu, panel, scene } = await buildTestScene({}); + + panel.state.$data?.setState({ data }); + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + scene.state.$timeRange?.setState({ timeZone: undefined }); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + const context: PluginExtensionPanelContext = { + id: 12, + pluginId: 'table', + title: 'Panel A', + timeZone: 'browser', + timeRange: { + from: 'now-5m', + to: 'now', + }, + targets: [ + { + refId: 'A', + // @ts-expect-error + query: 'QueryA', + }, + ], + dashboard: { + tags: ['database', 'panel'], + uid: 'dash-1', + title: 'My dashboard', + }, + scopedVars: { + a: { + text: 'a', + value: 'a', + }, + }, + data, + }; + + expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context })); + }); + + it('should contain menu item with category', async () => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Incident', + }, + ], + }); + + const { menu, panel } = await buildTestScene({}); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.length).toBe(7); + + const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu; + + expect(extensionsSubMenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Incident', + subMenu: expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident', + href: '/a/grafana-basic-app/declare-incident', + }), + ]), + }), + ]) + ); + }); + + it('should truncate category to 25 chars', async () => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Declare incident when pressing this amazing menu item', + }, + ], + }); + + const { menu, panel } = await buildTestScene({}); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.length).toBe(7); + + const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu; + + expect(extensionsSubMenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident when...', + subMenu: expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident', + href: '/a/grafana-basic-app/declare-incident', + }), + ]), + }), + ]) + ); + }); + + it('should contain menu item with category and append items without category after divider', async () => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Incident', + }, + { + id: '2', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Create forecast', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + }, + ], + }); + + const { menu, panel } = await buildTestScene({}); + + panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false }); + + mocks.contextSrv.hasAccessToExplore.mockReturnValue(true); + mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore')); + + menu.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(menu.state.items?.length).toBe(7); + + const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu; + + expect(extensionsSubMenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Incident', + subMenu: expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident', + href: '/a/grafana-basic-app/declare-incident', + }), + ]), + }), + expect.objectContaining({ + type: 'divider', + }), + expect.objectContaining({ + text: 'Create forecast', + }), + ]) + ); + }); + }); }); interface SceneOptions {} @@ -74,15 +483,24 @@ async function buildTestScene(options: SceneOptions) { pluginId: 'table', key: 'panel-12', menu, + $variables: new SceneVariableSet({ + variables: [new LocalValueVariable({ name: 'a', value: 'a', text: 'a' })], + }), $data: new SceneQueryRunner({ datasource: { uid: 'my-uid' }, - queries: [{ query: 'buu', refId: 'A' }], + queries: [{ query: 'QueryA', refId: 'A' }], }), }); const scene = new DashboardScene({ - title: 'hello', + title: 'My dashboard', uid: 'dash-1', + tags: ['database', 'panel'], + $timeRange: new SceneTimeRange({ + from: 'now-5m', + to: 'now', + timeZone: 'Africa/Abidjan', + }), meta: { canEdit: true, }, diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index d1902cb4da3..aa672d57b23 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -1,10 +1,26 @@ -import { InterpolateFunction, PanelMenuItem } from '@grafana/data'; -import { config, locationService, reportInteraction } from '@grafana/runtime'; -import { VizPanel, VizPanelMenu, sceneGraph } from '@grafana/scenes'; +import { + InterpolateFunction, + PanelMenuItem, + PluginExtensionPanelContext, + PluginExtensionPoints, + getTimeZone, +} from '@grafana/data'; +import { config, getPluginLinkExtensions, locationService, reportInteraction } from '@grafana/runtime'; +import { + LocalValueVariable, + SceneDataTransformer, + SceneGridRow, + SceneQueryRunner, + VizPanel, + VizPanelMenu, + sceneGraph, +} from '@grafana/scenes'; +import { DataQuery } from '@grafana/schema'; import { t } from 'app/core/internationalization'; import { PanelModel } from 'app/features/dashboard/state'; import { InspectTab } from 'app/features/inspector/types'; import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; +import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration'; import { ShareModal } from '../sharing/ShareModal'; @@ -14,6 +30,7 @@ import { getPanelIdForVizPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; import { LibraryVizPanel } from './LibraryVizPanel'; import { VizPanelLinks } from './PanelLinks'; +import { ShareQueryDataProvider } from './ShareQueryDataProvider'; /** * Behavior is called when VizPanelMenu is activated (ie when it's opened). @@ -151,6 +168,23 @@ export function panelMenuBehavior(menu: VizPanelMenu) { subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined, }); + if (dashboard instanceof DashboardScene) { + const { extensions } = getPluginLinkExtensions({ + extensionPointId: PluginExtensionPoints.DashboardPanelMenu, + context: createExtensionContext(panel, dashboard), + limitPerPlugin: 3, + }); + + if (extensions.length > 0 && !dashboard.state.isEditing) { + items.push({ + text: 'Extensions', + iconClassName: 'plug', + type: 'submenu', + subMenu: createExtensionSubMenu(extensions), + }); + } + } + if (moreSubMenu.length) { items.push({ type: 'submenu', @@ -196,3 +230,69 @@ export function getPanelLinksBehavior(panel: PanelModel) { panelLinksMenu.setState({ links }); }; } + +function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): PluginExtensionPanelContext { + const timeRange = sceneGraph.getTimeRange(panel); + let queryRunner = panel.state.$data; + let targets: DataQuery[] = []; + const id = getPanelIdForVizPanel(panel); + + if (queryRunner instanceof SceneDataTransformer) { + queryRunner = queryRunner.state.$data; + } + + if (queryRunner instanceof SceneQueryRunner) { + targets = queryRunner.state.queries; + } + + if (queryRunner instanceof ShareQueryDataProvider) { + targets = [queryRunner.state.query]; + } + + let scopedVars = {}; + + // Handle panel repeats scenario + if (panel.state.$variables) { + panel.state.$variables.state.variables.forEach((variable) => { + if (variable instanceof LocalValueVariable) { + scopedVars = { + ...scopedVars, + [variable.state.name]: { value: variable.getValue(), text: variable.getValueText() }, + }; + } + }); + } + + // Handle row repeats scenario + if (panel.parent?.parent instanceof SceneGridRow) { + const row = panel.parent.parent; + if (row.state.$variables) { + row.state.$variables.state.variables.forEach((variable) => { + if (variable instanceof LocalValueVariable) { + scopedVars = { + ...scopedVars, + [variable.state.name]: { value: variable.getValue(), text: variable.getValueText() }, + }; + } + }); + } + } + + return { + id, + pluginId: panel.state.pluginId, + title: panel.state.title, + timeRange: timeRange.state.value.raw, + timeZone: getTimeZone({ + timeZone: timeRange.getTimeZone(), + }), + dashboard: { + uid: dashboard.state.uid!, + title: dashboard.state.title, + tags: dashboard.state.tags || [], + }, + targets, + scopedVars, + data: queryRunner?.state.data, + }; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index debf9f4d680..f1d69e5e0f6 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -225,6 +225,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) return new DashboardScene({ title: oldModel.title, + tags: oldModel.tags || [], uid: oldModel.uid, id: oldModel.id, meta: oldModel.meta, diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index a948037d399..86579778835 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -14,7 +14,7 @@ import { VariableSupportType, } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { setPluginImportUtils } from '@grafana/runtime'; +import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime'; import { MultiValueVariable, SceneDataLayers, @@ -148,8 +148,12 @@ jest.mock('@grafana/runtime', () => ({ }, }, }, + setPluginExtensionGetter: jest.fn(), + getPluginLinkExtensions: jest.fn(), })); +const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); + jest.mock('@grafana/scenes', () => ({ ...jest.requireActual('@grafana/scenes'), sceneUtils: { @@ -159,6 +163,11 @@ jest.mock('@grafana/scenes', () => ({ })); describe('transformSceneToSaveModel', () => { + beforeEach(() => { + getPluginLinkExtensionsMock.mockRestore(); + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); + }); + describe('Given a simple scene with variables', () => { it('Should transform back to persisted model', () => { const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} }); diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index 8dab03567e2..2ad658f6a5b 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -1,7 +1,6 @@ import { getTimeZone, PanelMenuItem, - PluginExtensionLink, PluginExtensionPoints, urlUtil, type PluginExtensionPanelContext, @@ -26,7 +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 { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { store } from 'app/store/store'; @@ -375,53 +374,3 @@ function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): P data: panel.getQueryRunner().getLastResult(), }; } - -function createExtensionSubMenu(extensions: PluginExtensionLink[]): PanelMenuItem[] { - const categorized: Record = {}; - const uncategorized: PanelMenuItem[] = []; - - for (const extension of extensions) { - const category = extension.category; - - if (!category) { - uncategorized.push({ - text: truncateTitle(extension.title, 25), - href: extension.path, - onClick: extension.onClick, - }); - continue; - } - - if (!Array.isArray(categorized[category])) { - categorized[category] = []; - } - - categorized[category].push({ - text: truncateTitle(extension.title, 25), - href: extension.path, - onClick: extension.onClick, - }); - } - - const subMenu = Object.keys(categorized).reduce((subMenu: PanelMenuItem[], category) => { - subMenu.push({ - text: truncateTitle(category, 25), - type: 'group', - subMenu: categorized[category], - }); - return subMenu; - }, []); - - if (uncategorized.length > 0) { - if (subMenu.length > 0) { - subMenu.push({ - text: 'divider', - type: 'divider', - }); - } - - Array.prototype.push.apply(subMenu, uncategorized); - } - - return subMenu; -} diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 82ff3c34df9..277dbddcaa6 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -13,6 +13,8 @@ import { isDateTime, dateTime, PluginContextProvider, + PluginExtensionLink, + PanelMenuItem, } from '@grafana/data'; import { Modal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; @@ -249,3 +251,53 @@ export function truncateTitle(title: string, length: number): string { const part = title.slice(0, length - 3); return `${part.trimEnd()}...`; } + +export function createExtensionSubMenu(extensions: PluginExtensionLink[]): PanelMenuItem[] { + const categorized: Record = {}; + const uncategorized: PanelMenuItem[] = []; + + for (const extension of extensions) { + const category = extension.category; + + if (!category) { + uncategorized.push({ + text: truncateTitle(extension.title, 25), + href: extension.path, + onClick: extension.onClick, + }); + continue; + } + + if (!Array.isArray(categorized[category])) { + categorized[category] = []; + } + + categorized[category].push({ + text: truncateTitle(extension.title, 25), + href: extension.path, + onClick: extension.onClick, + }); + } + + const subMenu = Object.keys(categorized).reduce((subMenu: PanelMenuItem[], category) => { + subMenu.push({ + text: truncateTitle(category, 25), + type: 'group', + subMenu: categorized[category], + }); + return subMenu; + }, []); + + if (uncategorized.length > 0) { + if (subMenu.length > 0) { + subMenu.push({ + text: 'divider', + type: 'divider', + }); + } + + Array.prototype.push.apply(subMenu, uncategorized); + } + + return subMenu; +}