grafana_bak/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx
2025-04-01 10:38:02 +09:00

716 lines
23 KiB
TypeScript

import {
FieldType,
LoadingState,
PanelData,
PluginExtensionPanelContext,
PluginExtensionTypes,
getDefaultTimeRange,
toDataFrame,
urlUtil,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime';
import {
LocalValueVariable,
SceneQueryRunner,
SceneTimeRange,
SceneVariableSet,
VizPanel,
VizPanelMenu,
} from '@grafana/scenes';
import { contextSrv } from 'app/core/services/context_srv';
import { GetExploreUrlArguments } from 'app/core/utils/explore';
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
import * as storeModule from 'app/store/store';
import { AccessControlAction } from 'app/types';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { DashboardScene } from './DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
import { panelMenuBehavior } from './PanelMenuBehavior';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
const mocks = {
contextSrv: jest.mocked(contextSrv),
getExploreUrl: jest.fn(),
notifyApp: jest.fn(),
};
jest.mock('app/core/utils/explore', () => ({
...jest.requireActual('app/core/utils/explore'),
getExploreUrl: (options: GetExploreUrlArguments) => {
return mocks.getExploreUrl(options);
},
}));
jest.mock('app/core/services/context_srv');
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
}));
jest.mock('app/store/store', () => ({
dispatch: jest.fn(),
}));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
describe('panelMenuBehavior', () => {
beforeEach(() => {
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
beforeAll(() => {
locationService.push('/d/dash-1?from=now-5m&to=now');
});
it('Given standard panel', async () => {
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(6);
// verify view panel url keeps url params and adds viewPanel=<panel-key>
expect(menu.state.items?.[0].href).toBe('/d/dash-1?from=now-5m&to=now&viewPanel=panel-12');
// verify edit url keeps url time range
expect(menu.state.items?.[1].href).toBe('/d/dash-1?from=now-5m&to=now&editPanel=12');
// verify share
expect(menu.state.items?.[2].text).toBe('Share');
// verify explore url
expect(menu.state.items?.[3].href).toBe('/explore');
// 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: 'QueryA', refId: 'A' }]);
expect(getExploreArgs.scopedVars?.__sceneObject?.value).toBe(panel);
// verify inspect url keeps url params and adds inspect=<panel-key>
expect(menu.state.items?.[4].href).toBe('/d/dash-1?from=now-5m&to=now&inspect=panel-12');
expect(menu.state.items?.[4].subMenu).toBeDefined();
expect(menu.state.items?.[4].subMenu?.length).toBe(3);
});
it('should have reduced menu options when panel editor is open', async () => {
const { scene, menu, panel } = await buildTestScene({});
scene.setState({ editPanel: buildPanelEditScene(panel) });
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
config.unifiedAlertingEnabled = true;
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]);
menu.activate();
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(4);
expect(menu.state.items?.[0].text).toBe('Share');
expect(menu.state.items?.[1].text).toBe('Explore');
expect(menu.state.items?.[2].text).toBe('Inspect');
expect(menu.state.items?.[3].text).toBe('More...');
expect(menu.state.items?.[3].subMenu).toBeDefined();
expect(menu.state.items?.[3].subMenu?.length).toBe(2);
expect(menu.state.items?.[3].subMenu?.[0].text).toBe('New alert rule');
expect(menu.state.items?.[3].subMenu?.[1].text).toBe('Get help');
});
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',
}),
])
);
});
it('it should not contain remove and duplicate menu items when not in edit mode', async () => {
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?.find((i) => i.text === 'Remove')).toBeUndefined();
const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu;
expect(moreMenu?.find((i) => i.text === 'Duplicate')).toBeUndefined();
expect(moreMenu?.find((i) => i.text === 'Create library panel')).toBeUndefined();
});
it('it should contain remove and duplicate menu items when in edit mode', async () => {
const { scene, menu, panel } = await buildTestScene({});
scene.setState({ isEditing: true });
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?.find((i) => i.text === 'Remove')).toBeDefined();
const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu;
expect(moreMenu?.find((i) => i.text === 'Duplicate')).toBeDefined();
expect(moreMenu?.find((i) => i.text === 'Create library panel')).toBeDefined();
});
it('should only contain explore when embedded', async () => {
const { menu, panel } = await buildTestScene({ isEmbedded: true });
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(1);
expect(menu.state.items?.[0].text).toBe('Explore');
});
});
describe('onCreateAlert', () => {
beforeEach(() => {
jest.spyOn(storeModule, 'dispatch').mockImplementation(() => {});
jest.spyOn(locationService, 'push').mockImplementation(() => {});
jest.spyOn(urlUtil, 'renderUrl').mockImplementation((url, params) => `${url}?${JSON.stringify(params)}`);
});
it('should navigate to alert creation page on success', async () => {
const { menu, panel } = await buildTestScene({});
const mockFormValues = { someKey: 'someValue' };
config.unifiedAlertingEnabled = true;
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]);
jest
.spyOn(require('app/features/alerting/unified/utils/rule-form'), 'scenesPanelToRuleFormValues')
.mockResolvedValue(mockFormValues);
// activate the menu
menu.activate();
// wait for the menu to be activated
await new Promise((r) => setTimeout(r, 1));
// use userEvent mechanism to click the menu item
const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu;
const alertMenuItem = moreMenu?.find((i) => i.text === 'New alert rule')?.onClick;
expect(alertMenuItem).toBeDefined();
alertMenuItem?.({} as React.MouseEvent);
expect(scenesPanelToRuleFormValues).toHaveBeenCalledWith(panel);
});
it('should show error notification on failure', async () => {
const { menu, panel } = await buildTestScene({});
const mockError = new Error('Test error');
jest
.spyOn(require('app/features/alerting/unified/utils/rule-form'), 'scenesPanelToRuleFormValues')
.mockRejectedValue(mockError);
// Don't make notifyApp throw an error, just mock it
menu.activate();
await new Promise((r) => setTimeout(r, 1));
const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu;
const alertMenuItem = moreMenu?.find((i) => i.text === 'New alert rule')?.onClick;
expect(alertMenuItem).toBeDefined();
await alertMenuItem?.({} as React.MouseEvent);
await new Promise((r) => setTimeout(r, 0));
expect(scenesPanelToRuleFormValues).toHaveBeenCalledWith(panel);
});
it('should render "New alert rule" menu item when user has permissions to read and update alerts', async () => {
const { menu } = await buildTestScene({});
config.unifiedAlertingEnabled = true;
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]);
menu.activate();
await new Promise((r) => setTimeout(r, 1));
const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu;
expect(moreMenu?.find((i) => i.text === 'New alert rule')).toBeDefined();
});
it('should not contain "New alert rule" menu item when user does not have permissions to read and update alerts', async () => {
const { menu } = await buildTestScene({});
config.unifiedAlertingEnabled = true;
grantUserPermissions([AccessControlAction.AlertingRuleRead]);
menu.activate();
await new Promise((r) => setTimeout(r, 1));
const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu;
expect(moreMenu?.find((i) => i.text === 'New alert rule')).toBeUndefined();
});
it('should not contain "New alert rule" menu item when unifiedAlertingEnabled is false', async () => {
const { menu } = await buildTestScene({});
config.unifiedAlertingEnabled = false;
menu.activate();
await new Promise((r) => setTimeout(r, 1));
const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu;
expect(moreMenu?.find((i) => i.text === 'New alert rule')).toBeUndefined();
});
it('should not contain "New alert rule" menu item when user does not have permissions to read and update alerts', async () => {
const { menu } = await buildTestScene({});
config.unifiedAlertingEnabled = true;
grantUserPermissions([AccessControlAction.AlertingRuleRead]);
menu.activate();
await new Promise((r) => setTimeout(r, 1));
const moreMenu = menu.state.items?.find((i) => i.text === 'More...')?.subMenu;
const alertMenuItem = moreMenu?.find((i) => i.text === 'New alert rule')?.onClick;
expect(alertMenuItem).toBeUndefined();
});
afterEach(() => {
jest.restoreAllMocks();
});
});
});
interface SceneOptions {
isEmbedded?: boolean;
}
async function buildTestScene(options: SceneOptions) {
const menu = new VizPanelMenu({
$behaviors: [panelMenuBehavior],
});
const panel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-12',
menu,
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
$variables: new SceneVariableSet({
variables: [new LocalValueVariable({ name: 'a', value: 'a', text: 'a' })],
}),
$data: new SceneQueryRunner({
datasource: { uid: 'my-uid' },
queries: [{ query: 'QueryA', refId: 'A' }],
}),
});
const scene = new DashboardScene({
title: 'My dashboard',
uid: 'dash-1',
tags: ['database', 'panel'],
$timeRange: new SceneTimeRange({
from: 'now-5m',
to: 'now',
timeZone: 'Africa/Abidjan',
}),
meta: {
canEdit: true,
isEmbedded: options.isEmbedded ?? false,
},
body: DefaultGridLayoutManager.fromVizPanels([panel]),
});
await new Promise((r) => setTimeout(r, 1));
return { scene, panel, menu };
}