mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PanelChrome: Menu is wrapped in a render prop for full outside control (#60537)
* setup menu as a render prop sent down from PanelStateWrapper to PanelChrome * let the Dropdown take care of opening the menu in PanelChrome * menu and leftItems are on the right side of the header together * add storybook examples with menu * menu does not need to be a callback because it's opened in a Dropdown anyway * pass down to getPanelMenu whether or not data is streaming atm * stop loading data as well as streaming from menu * override menu's style where needed * reduce snapshot matching in tests
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { DataLink, GrafanaTheme2, PanelData } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
@@ -27,7 +27,7 @@ export interface Props {
|
||||
data: PanelData;
|
||||
}
|
||||
|
||||
export const PanelHeader: FC<Props> = ({ panel, error, isViewing, isEditing, data, alertState, dashboard }) => {
|
||||
export function PanelHeader({ panel, error, isViewing, isEditing, data, alertState, dashboard }: Props) {
|
||||
const onCancelQuery = () => panel.getQueryRunner().cancelQuery();
|
||||
const title = panel.getDisplayTitle();
|
||||
const className = cx('panel-header', !(isViewing || isEditing) ? 'grid-drag-handle' : '');
|
||||
@@ -81,7 +81,7 @@ export const PanelHeader: FC<Props> = ({ panel, error, isViewing, isEditing, dat
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const panelStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
|
||||
@@ -6,12 +6,17 @@ import { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
|
||||
|
||||
export interface Props {
|
||||
items: PanelMenuItem[];
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export class PanelHeaderMenu extends PureComponent<Props> {
|
||||
renderItems = (menu: PanelMenuItem[], isSubMenu = false) => {
|
||||
return (
|
||||
<ul className="dropdown-menu dropdown-menu--menu panel-menu" role={isSubMenu ? '' : 'menu'}>
|
||||
<ul
|
||||
className="dropdown-menu dropdown-menu--menu panel-menu"
|
||||
style={this.props.style}
|
||||
role={isSubMenu ? '' : 'menu'}
|
||||
>
|
||||
{menu.map((menuItem, idx: number) => {
|
||||
return (
|
||||
<PanelHeaderMenuItem
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
|
||||
import { PanelMenuItem } from '@grafana/data';
|
||||
import { LoadingState, PanelMenuItem } from '@grafana/data';
|
||||
import { getPanelStateForModel } from 'app/features/panel/state/selectors';
|
||||
import { useSelector } from 'app/types';
|
||||
|
||||
@@ -14,16 +14,17 @@ interface PanelHeaderMenuProviderApi {
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
dashboard: DashboardModel;
|
||||
loadingState?: LoadingState;
|
||||
children: (props: PanelHeaderMenuProviderApi) => ReactElement;
|
||||
}
|
||||
|
||||
export const PanelHeaderMenuProvider: FC<Props> = ({ panel, dashboard, children }) => {
|
||||
export function PanelHeaderMenuProvider({ panel, dashboard, loadingState, children }: Props) {
|
||||
const [items, setItems] = useState<PanelMenuItem[]>([]);
|
||||
const angularComponent = useSelector((state) => getPanelStateForModel(state, panel)?.angularComponent);
|
||||
|
||||
useEffect(() => {
|
||||
setItems(getPanelMenu(dashboard, panel, angularComponent));
|
||||
}, [dashboard, panel, angularComponent, setItems]);
|
||||
setItems(getPanelMenu(dashboard, panel, loadingState, angularComponent));
|
||||
}, [dashboard, panel, angularComponent, loadingState, setItems]);
|
||||
|
||||
return children({ items });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
children: (props: PanelHeaderMenuTriggerApi) => ReactElement;
|
||||
}
|
||||
|
||||
export const PanelHeaderMenuTrigger = ({ children, ...divProps }: Props) => {
|
||||
export function PanelHeaderMenuTrigger({ children, ...divProps }: Props) {
|
||||
const [clickCoordinates, setClickCoordinates] = useState<CartesianCoords2D>({ x: 0, y: 0 });
|
||||
const [panelMenuOpen, setPanelMenuOpen] = useState<boolean>(false);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const PanelHeaderMenuTrigger = ({ children, ...divProps }: Props) => {
|
||||
{children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function isClick(current: CartesianCoords2D, clicked: CartesianCoords2D, deadZone = 3.5): boolean {
|
||||
// A "deadzone" radius is added so that if the cursor is moved within this radius
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { LoadingState } from '@grafana/data';
|
||||
|
||||
import { DashboardModel, PanelModel } from '../../state';
|
||||
|
||||
@@ -8,15 +10,17 @@ import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider';
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
dashboard: DashboardModel;
|
||||
loadingState?: LoadingState;
|
||||
onClose: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const PanelHeaderMenuWrapper: FC<Props> = ({ panel, dashboard }) => {
|
||||
export function PanelHeaderMenuWrapper({ style, panel, dashboard, loadingState }: Props) {
|
||||
return (
|
||||
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard}>
|
||||
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard} loadingState={loadingState}>
|
||||
{({ items }) => {
|
||||
return <PanelHeaderMenu items={items} />;
|
||||
return <PanelHeaderMenu style={style} items={items} />;
|
||||
}}
|
||||
</PanelHeaderMenuProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import { DashboardModel, PanelModel } from '../state';
|
||||
import { loadSnapshotData } from '../utils/loadSnapshotData';
|
||||
|
||||
import { PanelHeader } from './PanelHeader/PanelHeader';
|
||||
import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper';
|
||||
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
|
||||
import { liveTimer } from './liveTimer';
|
||||
|
||||
@@ -589,6 +590,21 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
|
||||
const title = panel.getDisplayTitle();
|
||||
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
|
||||
|
||||
let menu;
|
||||
if (!dashboard.meta.publicDashboardAccessToken) {
|
||||
menu = (
|
||||
<div data-testid="panel-dropdown">
|
||||
<PanelHeaderMenuWrapper
|
||||
style={{ top: 0 }}
|
||||
panel={panel}
|
||||
dashboard={dashboard}
|
||||
loadingState={data.state}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.featureToggles.newPanelChromeUI) {
|
||||
return (
|
||||
<PanelChrome
|
||||
@@ -596,6 +612,7 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
|
||||
height={height}
|
||||
padding={padding}
|
||||
title={title}
|
||||
menu={menu}
|
||||
loadingState={data.state}
|
||||
status={{
|
||||
message: errorMessage,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PanelMenuItem } from '@grafana/data';
|
||||
import { LoadingState } from '@grafana/schema';
|
||||
import config from 'app/core/config';
|
||||
import * as actions from 'app/features/explore/state/main';
|
||||
import { setStore } from 'app/store/store';
|
||||
@@ -94,117 +95,147 @@ describe('getPanelMenu', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
describe('when panel is in view mode', () => {
|
||||
it('should return the correct panel menu items', () => {
|
||||
const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }];
|
||||
const ctrl: any = { getExtendedMenu };
|
||||
const scope: any = { $$childHead: { ctrl } };
|
||||
const angularComponent: any = { getScope: () => scope };
|
||||
const panel = new PanelModel({ isViewing: true });
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
it('should return the correct panel menu items when data is streaming', () => {
|
||||
const panel = new PanelModel({});
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
|
||||
const menuItems = getPanelMenu(dashboard, panel, angularComponent);
|
||||
expect(menuItems).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"iconClassName": "eye",
|
||||
"onClick": [Function],
|
||||
"shortcut": "v",
|
||||
"text": "View",
|
||||
},
|
||||
{
|
||||
"iconClassName": "edit",
|
||||
"onClick": [Function],
|
||||
"shortcut": "e",
|
||||
"text": "Edit",
|
||||
},
|
||||
{
|
||||
"iconClassName": "share-alt",
|
||||
"onClick": [Function],
|
||||
"shortcut": "p s",
|
||||
"text": "Share",
|
||||
},
|
||||
{
|
||||
"iconClassName": "compass",
|
||||
"onClick": [Function],
|
||||
"shortcut": "x",
|
||||
"text": "Explore",
|
||||
},
|
||||
{
|
||||
"iconClassName": "info-circle",
|
||||
"onClick": [Function],
|
||||
"shortcut": "i",
|
||||
"subMenu": [
|
||||
{
|
||||
"onClick": [Function],
|
||||
"text": "Panel JSON",
|
||||
},
|
||||
],
|
||||
"text": "Inspect",
|
||||
"type": "submenu",
|
||||
},
|
||||
{
|
||||
"iconClassName": "cube",
|
||||
"onClick": [Function],
|
||||
"subMenu": [
|
||||
{
|
||||
"href": undefined,
|
||||
"onClick": [Function],
|
||||
"shortcut": "p l",
|
||||
"text": "Toggle legend",
|
||||
},
|
||||
],
|
||||
"text": "More...",
|
||||
"type": "submenu",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
const menuItems = getPanelMenu(dashboard, panel, LoadingState.Streaming);
|
||||
expect(menuItems).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
iconClassName: 'circle',
|
||||
text: 'Stop query',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
describe('onNavigateToExplore', () => {
|
||||
const testSubUrl = '/testSubUrl';
|
||||
const testUrl = '/testUrl';
|
||||
const windowOpen = jest.fn();
|
||||
let event: any;
|
||||
let explore: PanelMenuItem;
|
||||
let navigateSpy: any;
|
||||
it('should return the correct panel menu items when data is loading', () => {
|
||||
const panel = new PanelModel({});
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
|
||||
beforeAll(() => {
|
||||
const panel = new PanelModel({});
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
const menuItems = getPanelMenu(dashboard, panel);
|
||||
explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem;
|
||||
navigateSpy = jest.spyOn(actions, 'navigateToExplore');
|
||||
window.open = windowOpen;
|
||||
|
||||
event = {
|
||||
ctrlKey: true,
|
||||
preventDefault: jest.fn(),
|
||||
};
|
||||
|
||||
setStore({ dispatch: jest.fn() } as any);
|
||||
});
|
||||
|
||||
it('should navigate to url without subUrl', () => {
|
||||
explore.onClick!(event);
|
||||
|
||||
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
|
||||
|
||||
openInNewWindow(testUrl);
|
||||
|
||||
expect(windowOpen).toHaveBeenLastCalledWith(testUrl);
|
||||
});
|
||||
|
||||
it('should navigate to url with subUrl', () => {
|
||||
config.appSubUrl = testSubUrl;
|
||||
explore.onClick!(event);
|
||||
|
||||
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
|
||||
|
||||
openInNewWindow(testUrl);
|
||||
|
||||
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`);
|
||||
});
|
||||
const menuItems = getPanelMenu(dashboard, panel, LoadingState.Loading);
|
||||
expect(menuItems).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
iconClassName: 'circle',
|
||||
text: 'Stop query',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when panel is in view mode', () => {
|
||||
it('should return the correct panel menu items', () => {
|
||||
const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }];
|
||||
const ctrl: any = { getExtendedMenu };
|
||||
const scope: any = { $$childHead: { ctrl } };
|
||||
const angularComponent: any = { getScope: () => scope };
|
||||
const panel = new PanelModel({ isViewing: true });
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
|
||||
const menuItems = getPanelMenu(dashboard, panel, undefined, angularComponent);
|
||||
expect(menuItems).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"iconClassName": "eye",
|
||||
"onClick": [Function],
|
||||
"shortcut": "v",
|
||||
"text": "View",
|
||||
},
|
||||
{
|
||||
"iconClassName": "edit",
|
||||
"onClick": [Function],
|
||||
"shortcut": "e",
|
||||
"text": "Edit",
|
||||
},
|
||||
{
|
||||
"iconClassName": "share-alt",
|
||||
"onClick": [Function],
|
||||
"shortcut": "p s",
|
||||
"text": "Share",
|
||||
},
|
||||
{
|
||||
"iconClassName": "compass",
|
||||
"onClick": [Function],
|
||||
"shortcut": "x",
|
||||
"text": "Explore",
|
||||
},
|
||||
{
|
||||
"iconClassName": "info-circle",
|
||||
"onClick": [Function],
|
||||
"shortcut": "i",
|
||||
"subMenu": [
|
||||
{
|
||||
"onClick": [Function],
|
||||
"text": "Panel JSON",
|
||||
},
|
||||
],
|
||||
"text": "Inspect",
|
||||
"type": "submenu",
|
||||
},
|
||||
{
|
||||
"iconClassName": "cube",
|
||||
"onClick": [Function],
|
||||
"subMenu": [
|
||||
{
|
||||
"href": undefined,
|
||||
"onClick": [Function],
|
||||
"shortcut": "p l",
|
||||
"text": "Toggle legend",
|
||||
},
|
||||
],
|
||||
"text": "More...",
|
||||
"type": "submenu",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onNavigateToExplore', () => {
|
||||
const testSubUrl = '/testSubUrl';
|
||||
const testUrl = '/testUrl';
|
||||
const windowOpen = jest.fn();
|
||||
let event: any;
|
||||
let explore: PanelMenuItem;
|
||||
let navigateSpy: any;
|
||||
|
||||
beforeAll(() => {
|
||||
const panel = new PanelModel({});
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
const menuItems = getPanelMenu(dashboard, panel);
|
||||
explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem;
|
||||
navigateSpy = jest.spyOn(actions, 'navigateToExplore');
|
||||
window.open = windowOpen;
|
||||
|
||||
event = {
|
||||
ctrlKey: true,
|
||||
preventDefault: jest.fn(),
|
||||
};
|
||||
|
||||
setStore({ dispatch: jest.fn() } as any);
|
||||
});
|
||||
|
||||
it('should navigate to url without subUrl', () => {
|
||||
explore.onClick!(event);
|
||||
|
||||
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
|
||||
|
||||
openInNewWindow(testUrl);
|
||||
|
||||
expect(windowOpen).toHaveBeenLastCalledWith(testUrl);
|
||||
});
|
||||
|
||||
it('should navigate to url with subUrl', () => {
|
||||
config.appSubUrl = testSubUrl;
|
||||
explore.onClick!(event);
|
||||
|
||||
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
|
||||
|
||||
openInNewWindow(testUrl);
|
||||
|
||||
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PanelMenuItem } from '@grafana/data';
|
||||
import { AngularComponent, getDataSourceSrv, locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { LoadingState } from '@grafana/schema';
|
||||
import { PanelCtrl } from 'app/angular/panel/panel_ctrl';
|
||||
import config from 'app/core/config';
|
||||
import { t } from 'app/core/internationalization';
|
||||
@@ -26,6 +27,7 @@ import { getTimeSrv } from '../services/TimeSrv';
|
||||
export function getPanelMenu(
|
||||
dashboard: DashboardModel,
|
||||
panel: PanelModel,
|
||||
loadingState?: LoadingState,
|
||||
angularComponent?: AngularComponent | null
|
||||
): PanelMenuItem[] {
|
||||
const onViewPanel = (event: React.MouseEvent<any>) => {
|
||||
@@ -98,6 +100,12 @@ export function getPanelMenu(
|
||||
event.preventDefault();
|
||||
toggleLegend(panel);
|
||||
};
|
||||
|
||||
const onCancelStreaming = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
panel.getQueryRunner().cancelQuery();
|
||||
};
|
||||
|
||||
const menu: PanelMenuItem[] = [];
|
||||
|
||||
if (!panel.isEditing) {
|
||||
@@ -119,6 +127,17 @@ export function getPanelMenu(
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
dashboard.canEditPanel(panel) &&
|
||||
(loadingState === LoadingState.Streaming || loadingState === LoadingState.Loading)
|
||||
) {
|
||||
menu.push({
|
||||
text: 'Stop query',
|
||||
iconClassName: 'circle',
|
||||
onClick: onCancelStreaming,
|
||||
});
|
||||
}
|
||||
|
||||
const shareTextTranslation = t('panel.header-menu.share', `Share`);
|
||||
|
||||
menu.push({
|
||||
|
||||
Reference in New Issue
Block a user