DashboardScene: Show plugin extensions in panel menu (#78702)
* DashboardScene: Show plugin extensions in panel menu * FIx test * Nits * Nit * Review nits
This commit is contained in:
@@ -6,7 +6,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
|||||||
|
|
||||||
import { PanelProps } from '@grafana/data';
|
import { PanelProps } from '@grafana/data';
|
||||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
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 { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||||
|
|
||||||
import { setupLoadDashboardMock } from '../utils/test-utils';
|
import { setupLoadDashboardMock } from '../utils/test-utils';
|
||||||
@@ -15,6 +15,8 @@ import { DashboardScenePage, Props } from './DashboardScenePage';
|
|||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
...jest.requireActual('@grafana/runtime'),
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
setPluginExtensionGetter: jest.fn(),
|
||||||
|
getPluginLinkExtensions: jest.fn(),
|
||||||
getDataSourceSrv: () => {
|
getDataSourceSrv: () => {
|
||||||
return {
|
return {
|
||||||
get: jest.fn().mockResolvedValue({}),
|
get: jest.fn().mockResolvedValue({}),
|
||||||
@@ -23,6 +25,8 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const context = getGrafanaContextMock();
|
const context = getGrafanaContextMock();
|
||||||
const props: Props = {
|
const props: Props = {
|
||||||
@@ -97,6 +101,8 @@ describe('DashboardScenePage', () => {
|
|||||||
// hacky way because mocking autosizer does not work
|
// hacky way because mocking autosizer does not work
|
||||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
|
||||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
|
||||||
|
getPluginLinkExtensionsMock.mockRestore();
|
||||||
|
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Can render dashboard', async () => {
|
it('Can render dashboard', async () => {
|
||||||
@@ -123,6 +129,7 @@ describe('DashboardScenePage', () => {
|
|||||||
await userEvent.click(screen.getByLabelText('Menu for panel with title Panel B'));
|
await userEvent.click(screen.getByLabelText('Menu for panel with title Panel B'));
|
||||||
|
|
||||||
const inspectMenuItem = await screen.findAllByText('Inspect');
|
const inspectMenuItem = await screen.findAllByText('Inspect');
|
||||||
|
|
||||||
act(() => fireEvent.click(inspectMenuItem[0]));
|
act(() => fireEvent.click(inspectMenuItem[0]));
|
||||||
|
|
||||||
expect(await screen.findByText('Inspect: Panel B')).toBeInTheDocument();
|
expect(await screen.findByText('Inspect: Panel B')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import { setupKeyboardShortcuts } from './keyboardShortcuts';
|
|||||||
export interface DashboardSceneState extends SceneObjectState {
|
export interface DashboardSceneState extends SceneObjectState {
|
||||||
/** The title */
|
/** The title */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Tags */
|
||||||
|
tags?: string[];
|
||||||
/** A uid when saved */
|
/** A uid when saved */
|
||||||
uid?: string;
|
uid?: string;
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
|
import {
|
||||||
|
FieldType,
|
||||||
|
LoadingState,
|
||||||
|
PanelData,
|
||||||
|
PluginExtensionPanelContext,
|
||||||
|
PluginExtensionTypes,
|
||||||
|
getDefaultTimeRange,
|
||||||
|
toDataFrame,
|
||||||
|
} from '@grafana/data';
|
||||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { getPluginLinkExtensions, locationService } from '@grafana/runtime';
|
||||||
import { SceneGridItem, SceneGridLayout, SceneQueryRunner, VizPanel, VizPanelMenu } from '@grafana/scenes';
|
import {
|
||||||
|
LocalValueVariable,
|
||||||
|
SceneGridItem,
|
||||||
|
SceneGridLayout,
|
||||||
|
SceneQueryRunner,
|
||||||
|
SceneTimeRange,
|
||||||
|
SceneVariableSet,
|
||||||
|
VizPanel,
|
||||||
|
VizPanelMenu,
|
||||||
|
} from '@grafana/scenes';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { GetExploreUrlArguments } from 'app/core/utils/explore';
|
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('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', () => {
|
describe('panelMenuBehavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getPluginLinkExtensionsMock.mockRestore();
|
||||||
|
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||||
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
locationService.push('/scenes/dashboard/dash-1?from=now-5m&to=now');
|
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
|
// Verify explore url is called with correct arguments
|
||||||
const getExploreArgs: GetExploreUrlArguments = mocks.getExploreUrl.mock.calls[0][0];
|
const getExploreArgs: GetExploreUrlArguments = mocks.getExploreUrl.mock.calls[0][0];
|
||||||
expect(getExploreArgs.dsRef).toEqual({ uid: 'my-uid' });
|
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);
|
expect(getExploreArgs.scopedVars?.__sceneObject?.value).toBe(panel);
|
||||||
|
|
||||||
// verify inspect url keeps url params and adds inspect=<panel-key>
|
// verify inspect url keeps url params and adds inspect=<panel-key>
|
||||||
@@ -60,6 +91,384 @@ describe('panelMenuBehavior', () => {
|
|||||||
|
|
||||||
expect(menu.state.items?.[4].subMenu?.length).toBe(3);
|
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 {}
|
interface SceneOptions {}
|
||||||
@@ -74,15 +483,24 @@ async function buildTestScene(options: SceneOptions) {
|
|||||||
pluginId: 'table',
|
pluginId: 'table',
|
||||||
key: 'panel-12',
|
key: 'panel-12',
|
||||||
menu,
|
menu,
|
||||||
|
$variables: new SceneVariableSet({
|
||||||
|
variables: [new LocalValueVariable({ name: 'a', value: 'a', text: 'a' })],
|
||||||
|
}),
|
||||||
$data: new SceneQueryRunner({
|
$data: new SceneQueryRunner({
|
||||||
datasource: { uid: 'my-uid' },
|
datasource: { uid: 'my-uid' },
|
||||||
queries: [{ query: 'buu', refId: 'A' }],
|
queries: [{ query: 'QueryA', refId: 'A' }],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const scene = new DashboardScene({
|
const scene = new DashboardScene({
|
||||||
title: 'hello',
|
title: 'My dashboard',
|
||||||
uid: 'dash-1',
|
uid: 'dash-1',
|
||||||
|
tags: ['database', 'panel'],
|
||||||
|
$timeRange: new SceneTimeRange({
|
||||||
|
from: 'now-5m',
|
||||||
|
to: 'now',
|
||||||
|
timeZone: 'Africa/Abidjan',
|
||||||
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { InterpolateFunction, PanelMenuItem } from '@grafana/data';
|
import {
|
||||||
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
InterpolateFunction,
|
||||||
import { VizPanel, VizPanelMenu, sceneGraph } from '@grafana/scenes';
|
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 { t } from 'app/core/internationalization';
|
||||||
import { PanelModel } from 'app/features/dashboard/state';
|
import { PanelModel } from 'app/features/dashboard/state';
|
||||||
import { InspectTab } from 'app/features/inspector/types';
|
import { InspectTab } from 'app/features/inspector/types';
|
||||||
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
|
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
|
||||||
|
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
|
||||||
import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration';
|
import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration';
|
||||||
|
|
||||||
import { ShareModal } from '../sharing/ShareModal';
|
import { ShareModal } from '../sharing/ShareModal';
|
||||||
@@ -14,6 +30,7 @@ import { getPanelIdForVizPanel } from '../utils/utils';
|
|||||||
import { DashboardScene } from './DashboardScene';
|
import { DashboardScene } from './DashboardScene';
|
||||||
import { LibraryVizPanel } from './LibraryVizPanel';
|
import { LibraryVizPanel } from './LibraryVizPanel';
|
||||||
import { VizPanelLinks } from './PanelLinks';
|
import { VizPanelLinks } from './PanelLinks';
|
||||||
|
import { ShareQueryDataProvider } from './ShareQueryDataProvider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Behavior is called when VizPanelMenu is activated (ie when it's opened).
|
* 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,
|
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) {
|
if (moreSubMenu.length) {
|
||||||
items.push({
|
items.push({
|
||||||
type: 'submenu',
|
type: 'submenu',
|
||||||
@@ -196,3 +230,69 @@ export function getPanelLinksBehavior(panel: PanelModel) {
|
|||||||
panelLinksMenu.setState({ links });
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
|
|||||||
|
|
||||||
return new DashboardScene({
|
return new DashboardScene({
|
||||||
title: oldModel.title,
|
title: oldModel.title,
|
||||||
|
tags: oldModel.tags || [],
|
||||||
uid: oldModel.uid,
|
uid: oldModel.uid,
|
||||||
id: oldModel.id,
|
id: oldModel.id,
|
||||||
meta: oldModel.meta,
|
meta: oldModel.meta,
|
||||||
|
|||||||
+10
-1
@@ -14,7 +14,7 @@ import {
|
|||||||
VariableSupportType,
|
VariableSupportType,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
|
||||||
import { setPluginImportUtils } from '@grafana/runtime';
|
import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
MultiValueVariable,
|
MultiValueVariable,
|
||||||
SceneDataLayers,
|
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.mock('@grafana/scenes', () => ({
|
||||||
...jest.requireActual('@grafana/scenes'),
|
...jest.requireActual('@grafana/scenes'),
|
||||||
sceneUtils: {
|
sceneUtils: {
|
||||||
@@ -159,6 +163,11 @@ jest.mock('@grafana/scenes', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('transformSceneToSaveModel', () => {
|
describe('transformSceneToSaveModel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getPluginLinkExtensionsMock.mockRestore();
|
||||||
|
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||||
|
});
|
||||||
|
|
||||||
describe('Given a simple scene with variables', () => {
|
describe('Given a simple scene with variables', () => {
|
||||||
it('Should transform back to persisted model', () => {
|
it('Should transform back to persisted model', () => {
|
||||||
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
getTimeZone,
|
getTimeZone,
|
||||||
PanelMenuItem,
|
PanelMenuItem,
|
||||||
PluginExtensionLink,
|
|
||||||
PluginExtensionPoints,
|
PluginExtensionPoints,
|
||||||
urlUtil,
|
urlUtil,
|
||||||
type PluginExtensionPanelContext,
|
type PluginExtensionPanelContext,
|
||||||
@@ -26,7 +25,7 @@ import {
|
|||||||
} from 'app/features/dashboard/utils/panel';
|
} from 'app/features/dashboard/utils/panel';
|
||||||
import { InspectTab } from 'app/features/inspector/types';
|
import { InspectTab } from 'app/features/inspector/types';
|
||||||
import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard';
|
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 { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
|
||||||
import { store } from 'app/store/store';
|
import { store } from 'app/store/store';
|
||||||
|
|
||||||
@@ -375,53 +374,3 @@ function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): P
|
|||||||
data: panel.getQueryRunner().getLastResult(),
|
data: panel.getQueryRunner().getLastResult(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createExtensionSubMenu(extensions: PluginExtensionLink[]): PanelMenuItem[] {
|
|
||||||
const categorized: Record<string, PanelMenuItem[]> = {};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
isDateTime,
|
isDateTime,
|
||||||
dateTime,
|
dateTime,
|
||||||
PluginContextProvider,
|
PluginContextProvider,
|
||||||
|
PluginExtensionLink,
|
||||||
|
PanelMenuItem,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { Modal } from '@grafana/ui';
|
import { Modal } from '@grafana/ui';
|
||||||
import appEvents from 'app/core/app_events';
|
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);
|
const part = title.slice(0, length - 3);
|
||||||
return `${part.trimEnd()}...`;
|
return `${part.trimEnd()}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createExtensionSubMenu(extensions: PluginExtensionLink[]): PanelMenuItem[] {
|
||||||
|
const categorized: Record<string, PanelMenuItem[]> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user