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:
Polina Boneva 2023-01-12 11:10:09 +02:00 committed by GitHub
parent f85a948214
commit 84eb275c8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 338 additions and 195 deletions

View File

@ -72,17 +72,16 @@ Dropdown.displayName = 'Dropdown';
const getStyles = (duration: number) => { const getStyles = (duration: number) => {
return { return {
appear: css` appear: css({
opacity: 0; opacity: '0',
position: relative; position: 'relative',
transform: scaleY(0.5); transform: 'scaleY(0.5)',
transform-origin: top; transformOrigin: 'top',
`, }),
appearActive: css` appearActive: css({
opacity: 1; opacity: '1',
transform: scaleY(1); transform: 'scaleY(1)',
transition: transform ${duration}ms cubic-bezier(0.2, 0, 0.2, 1), transition: `transform ${duration}ms cubic-bezier(0.2, 0, 0.2, 1), opacity ${duration}ms cubic-bezier(0.2, 0, 0.2, 1)`,
opacity ${duration}ms cubic-bezier(0.2, 0, 0.2, 1); }),
`,
}; };
}; };

View File

@ -35,11 +35,10 @@ function getContentStyle(): CSSProperties {
}; };
} }
function renderPanel(name: string, overrides: Partial<PanelChromeProps>) { function renderPanel(name: string, overrides?: Partial<PanelChromeProps>) {
const props: PanelChromeProps = { const props: PanelChromeProps = {
width: 400, width: 400,
height: 130, height: 130,
title: 'Default title',
children: () => undefined, children: () => undefined,
}; };
@ -56,6 +55,37 @@ function renderPanel(name: string, overrides: Partial<PanelChromeProps>) {
); );
} }
const menu = (
<Menu>
<Menu.Item label="View" icon="eye" />
<Menu.Item label="Edit" icon="edit" />
<Menu.Item label="Share" icon="share-alt" />
<Menu.Item label="Explore" icon="compass" />
<Menu.Item
label="Inspect"
icon="info-circle"
childItems={[
<Menu.Item key="subitem1" label="Data" />,
<Menu.Item key="subitem2" label="Query" />,
<Menu.Item key="subitem3" label="Panel JSON" />,
]}
/>
<Menu.Item
label="More"
icon="cube"
childItems={[
<Menu.Item key="subitem1" label="Duplicate" />,
<Menu.Item key="subitem2" label="Copy" />,
<Menu.Item key="subitem3" label="Create library panel" />,
<Menu.Item key="subitem4" label="Hide legend" />,
<Menu.Item key="subitem5" label="Get help" />,
]}
/>
<Menu.Divider />
<Menu.Item label="Remove" icon="trash-alt" />
</Menu>
);
export const Examples = () => { export const Examples = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -65,33 +95,81 @@ export const Examples = () => {
<DashboardStoryCanvas> <DashboardStoryCanvas>
<HorizontalGroup spacing="md" align="flex-start"> <HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
{renderPanel('Default panel with error status', { {renderPanel('Error status', {
title: 'Default title', title: 'Default title',
status: { status: {
message: 'Error text', message: 'Error text',
onClick: action('ErrorIndicator: onClick fired'), onClick: action('ErrorIndicator: onClick fired'),
}, },
})} })}
{renderPanel('No padding with error state', { {renderPanel('No padding, error loadingState', {
padding: 'none', padding: 'none',
title: 'Default title', title: 'Default title',
loadingState: LoadingState.Error, loadingState: LoadingState.Error,
})} })}
{renderPanel('Default panel with streaming state', { {renderPanel('No title, error loadingState', {
loadingState: LoadingState.Error,
})}
{renderPanel('Streaming loadingState', {
title: 'Default title', title: 'Default title',
loadingState: LoadingState.Streaming, loadingState: LoadingState.Streaming,
})} })}
{renderPanel('Loading loadingState', {
title: 'Default title',
loadingState: LoadingState.Loading,
})}
</VerticalGroup> </VerticalGroup>
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
{renderPanel('No title', { title: '' })} {renderPanel('Default panel: no non-required props')}
{renderPanel('No padding, no title', {
padding: 'none',
})}
{renderPanel('Very long title', { {renderPanel('Very long title', {
title: 'Very long title that should get ellipsis when there is no more space', title: 'Very long title that should get ellipsis when there is no more space',
})} })}
{renderPanel('No title, streaming loadingState', {
loadingState: LoadingState.Streaming,
})}
{renderPanel('No title, loading loadingState', {
loadingState: LoadingState.Loading,
})}
</VerticalGroup>
<VerticalGroup spacing="md">
{renderPanel('Error status, menu', {
title: 'Default title',
menu,
status: {
message: 'Error text',
onClick: action('ErrorIndicator: onClick fired'),
},
})}
{renderPanel('No padding, error loadingState, menu', {
padding: 'none',
title: 'Default title',
menu,
loadingState: LoadingState.Error,
})}
{renderPanel('No title, error loadingState, menu', {
menu,
loadingState: LoadingState.Error,
})}
{renderPanel('Streaming loadingState, menu', {
title: 'Default title',
menu,
loadingState: LoadingState.Streaming,
})}
{renderPanel('Loading loadingState, menu', {
title: 'Default title',
menu,
loadingState: LoadingState.Loading,
})}
</VerticalGroup> </VerticalGroup>
</HorizontalGroup> </HorizontalGroup>
<HorizontalGroup spacing="md" align="flex-start"> <HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
{renderPanel('Default panel with deprecated error indicator', { {renderPanel('Deprecated error indicator', {
title: 'Default title', title: 'Default title',
leftItems: [ leftItems: [
<PanelChrome.ErrorIndicator <PanelChrome.ErrorIndicator
@ -101,7 +179,7 @@ export const Examples = () => {
/>, />,
], ],
})} })}
{renderPanel('No padding with deprecated loading indicator', { {renderPanel('No padding, deprecated loading indicator', {
padding: 'none', padding: 'none',
title: 'Default title', title: 'Default title',
leftItems: [ leftItems: [
@ -113,6 +191,19 @@ export const Examples = () => {
], ],
})} })}
</VerticalGroup> </VerticalGroup>
<VerticalGroup spacing="md">
{renderPanel('Deprecated error indicator, menu', {
title: 'Default title',
menu,
leftItems: [
<PanelChrome.ErrorIndicator
key="errorIndicator"
error="Error text"
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
})}
</VerticalGroup>
</HorizontalGroup> </HorizontalGroup>
</DashboardStoryCanvas> </DashboardStoryCanvas>
); );
@ -166,37 +257,6 @@ const titleItems: PanelChromeInfoState[] = [
}, },
]; ];
const menu = (
<Menu>
<Menu.Item label="View" icon="eye" />
<Menu.Item label="Edit" icon="edit" />
<Menu.Item label="Share" icon="share-alt" />
<Menu.Item label="Explore" icon="compass" />
<Menu.Item
label="Inspect"
icon="info-circle"
childItems={[
<Menu.Item key="subitem1" label="Data" />,
<Menu.Item key="subitem2" label="Query" />,
<Menu.Item key="subitem3" label="Panel JSON" />,
]}
/>
<Menu.Item
label="More"
icon="cube"
childItems={[
<Menu.Item key="subitem1" label="Duplicate" />,
<Menu.Item key="subitem2" label="Copy" />,
<Menu.Item key="subitem3" label="Create library panel" />,
<Menu.Item key="subitem4" label="Hide legend" />,
<Menu.Item key="subitem5" label="Get help" />,
]}
/>
<Menu.Divider />
<Menu.Item label="Remove" icon="trash-alt" />
</Menu>
);
Basic.argTypes = { Basic.argTypes = {
leftItems: { leftItems: {
options: Object.keys(leftItems), options: Object.keys(leftItems),

View File

@ -40,7 +40,7 @@ export interface PanelChromeProps {
padding?: PanelPadding; padding?: PanelPadding;
title?: string; title?: string;
titleItems?: PanelChromeInfoState[]; titleItems?: PanelChromeInfoState[];
menu?: ReactElement; menu?: ReactElement | (() => ReactElement);
/** dragClass, hoverHeader not yet implemented */ /** dragClass, hoverHeader not yet implemented */
// dragClass?: string; // dragClass?: string;
hoverHeader?: boolean; hoverHeader?: boolean;
@ -100,15 +100,15 @@ export function PanelChrome({
const showStreaming = loadingState === LoadingState.Streaming && !isUsingDeprecatedLeftItems; const showStreaming = loadingState === LoadingState.Streaming && !isUsingDeprecatedLeftItems;
const renderStatus = () => { const renderStatus = () => {
if (isUsingDeprecatedLeftItems) { const showError = loadingState === LoadingState.Error || status?.message;
return <div className={cx(styles.rightAligned, styles.items)}>{itemsRenderer(leftItems, (item) => item)}</div>; if (!isUsingDeprecatedLeftItems && showError) {
} else { return (
const showError = loadingState === LoadingState.Error || status?.message;
return showError ? (
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<PanelStatus message={status?.message} onClick={status?.onClick} /> <PanelStatus message={status?.message} onClick={status?.onClick} />
</div> </div>
) : null; );
} else {
return null;
} }
}; };
return ( return (
@ -150,18 +150,22 @@ export function PanelChrome({
</div> </div>
)} )}
{menu && ( <div className={styles.rightAligned}>
<Dropdown overlay={menu} placement="bottom"> {menu && (
<div className={cx(styles.item, styles.menuItem, 'menu-icon')} data-testid="menu-icon" style={itemStyles}> <Dropdown overlay={menu} placement="bottom">
<IconButton <div className={cx(styles.item, styles.menuItem, 'menu-icon')} data-testid="menu-icon" style={itemStyles}>
ariaLabel={`Menu for panel with ${title ? `title ${title}` : 'no title'}`} <IconButton
tooltip="Menu" ariaLabel={`Menu for panel with ${title ? `title ${title}` : 'no title'}`}
name="ellipsis-v" tooltip="Menu"
size="sm" name="ellipsis-v"
/> size="sm"
</div> />
</Dropdown> </div>
)} </Dropdown>
)}
{isUsingDeprecatedLeftItems && <div className={styles.items}>{itemsRenderer(leftItems, (item) => item)}</div>}
</div>
{renderStatus()} {renderStatus()}
</div> </div>
@ -286,7 +290,10 @@ const getStyles = (theme: GrafanaTheme2) => {
justifyContent: 'center', justifyContent: 'center',
}), }),
rightAligned: css({ rightAligned: css({
label: 'right-aligned-container',
marginLeft: 'auto', marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
}), }),
}; };
}; };

View File

@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React, { FC } from 'react'; import React from 'react';
import { DataLink, GrafanaTheme2, PanelData } from '@grafana/data'; import { DataLink, GrafanaTheme2, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
@ -27,7 +27,7 @@ export interface Props {
data: PanelData; 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 onCancelQuery = () => panel.getQueryRunner().cancelQuery();
const title = panel.getDisplayTitle(); const title = panel.getDisplayTitle();
const className = cx('panel-header', !(isViewing || isEditing) ? 'grid-drag-handle' : ''); 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> </div>
</> </>
); );
}; }
const panelStyles = (theme: GrafanaTheme2) => { const panelStyles = (theme: GrafanaTheme2) => {
return { return {

View File

@ -6,12 +6,17 @@ import { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
export interface Props { export interface Props {
items: PanelMenuItem[]; items: PanelMenuItem[];
style?: React.CSSProperties;
} }
export class PanelHeaderMenu extends PureComponent<Props> { export class PanelHeaderMenu extends PureComponent<Props> {
renderItems = (menu: PanelMenuItem[], isSubMenu = false) => { renderItems = (menu: PanelMenuItem[], isSubMenu = false) => {
return ( 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) => { {menu.map((menuItem, idx: number) => {
return ( return (
<PanelHeaderMenuItem <PanelHeaderMenuItem

View File

@ -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 { getPanelStateForModel } from 'app/features/panel/state/selectors';
import { useSelector } from 'app/types'; import { useSelector } from 'app/types';
@ -14,16 +14,17 @@ interface PanelHeaderMenuProviderApi {
interface Props { interface Props {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
loadingState?: LoadingState;
children: (props: PanelHeaderMenuProviderApi) => ReactElement; 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 [items, setItems] = useState<PanelMenuItem[]>([]);
const angularComponent = useSelector((state) => getPanelStateForModel(state, panel)?.angularComponent); const angularComponent = useSelector((state) => getPanelStateForModel(state, panel)?.angularComponent);
useEffect(() => { useEffect(() => {
setItems(getPanelMenu(dashboard, panel, angularComponent)); setItems(getPanelMenu(dashboard, panel, loadingState, angularComponent));
}, [dashboard, panel, angularComponent, setItems]); }, [dashboard, panel, angularComponent, loadingState, setItems]);
return children({ items }); return children({ items });
}; }

View File

@ -11,7 +11,7 @@ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
children: (props: PanelHeaderMenuTriggerApi) => ReactElement; 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 [clickCoordinates, setClickCoordinates] = useState<CartesianCoords2D>({ x: 0, y: 0 });
const [panelMenuOpen, setPanelMenuOpen] = useState<boolean>(false); const [panelMenuOpen, setPanelMenuOpen] = useState<boolean>(false);
@ -38,7 +38,7 @@ export const PanelHeaderMenuTrigger = ({ children, ...divProps }: Props) => {
{children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })} {children({ panelMenuOpen, closeMenu: () => setPanelMenuOpen(false) })}
</header> </header>
); );
}; }
function isClick(current: CartesianCoords2D, clicked: CartesianCoords2D, deadZone = 3.5): boolean { 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 // A "deadzone" radius is added so that if the cursor is moved within this radius

View File

@ -1,4 +1,6 @@
import React, { FC } from 'react'; import React from 'react';
import { LoadingState } from '@grafana/data';
import { DashboardModel, PanelModel } from '../../state'; import { DashboardModel, PanelModel } from '../../state';
@ -8,15 +10,17 @@ import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider';
interface Props { interface Props {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
loadingState?: LoadingState;
onClose: () => void; onClose: () => void;
style?: React.CSSProperties;
} }
export const PanelHeaderMenuWrapper: FC<Props> = ({ panel, dashboard }) => { export function PanelHeaderMenuWrapper({ style, panel, dashboard, loadingState }: Props) {
return ( return (
<PanelHeaderMenuProvider panel={panel} dashboard={dashboard}> <PanelHeaderMenuProvider panel={panel} dashboard={dashboard} loadingState={loadingState}>
{({ items }) => { {({ items }) => {
return <PanelHeaderMenu items={items} />; return <PanelHeaderMenu style={style} items={items} />;
}} }}
</PanelHeaderMenuProvider> </PanelHeaderMenuProvider>
); );
}; }

View File

@ -46,6 +46,7 @@ import { DashboardModel, PanelModel } from '../state';
import { loadSnapshotData } from '../utils/loadSnapshotData'; import { loadSnapshotData } from '../utils/loadSnapshotData';
import { PanelHeader } from './PanelHeader/PanelHeader'; import { PanelHeader } from './PanelHeader/PanelHeader';
import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper';
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
import { liveTimer } from './liveTimer'; import { liveTimer } from './liveTimer';
@ -589,6 +590,21 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
const title = panel.getDisplayTitle(); const title = panel.getDisplayTitle();
const padding: PanelPadding = plugin.noPadding ? 'none' : 'md'; 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) { if (config.featureToggles.newPanelChromeUI) {
return ( return (
<PanelChrome <PanelChrome
@ -596,6 +612,7 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
height={height} height={height}
padding={padding} padding={padding}
title={title} title={title}
menu={menu}
loadingState={data.state} loadingState={data.state}
status={{ status={{
message: errorMessage, message: errorMessage,

View File

@ -1,4 +1,5 @@
import { PanelMenuItem } from '@grafana/data'; import { PanelMenuItem } from '@grafana/data';
import { LoadingState } from '@grafana/schema';
import config from 'app/core/config'; import config from 'app/core/config';
import * as actions from 'app/features/explore/state/main'; import * as actions from 'app/features/explore/state/main';
import { setStore } from 'app/store/store'; 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 when data is streaming', () => {
it('should return the correct panel menu items', () => { const panel = new PanelModel({});
const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }]; const dashboard = createDashboardModelFixture({});
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, angularComponent); const menuItems = getPanelMenu(dashboard, panel, LoadingState.Streaming);
expect(menuItems).toMatchInlineSnapshot(` expect(menuItems).toEqual(
[ expect.arrayContaining([
{ expect.objectContaining({
"iconClassName": "eye", iconClassName: 'circle',
"onClick": [Function], text: 'Stop query',
"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', () => { it('should return the correct panel menu items when data is loading', () => {
const testSubUrl = '/testSubUrl'; const panel = new PanelModel({});
const testUrl = '/testUrl'; const dashboard = createDashboardModelFixture({});
const windowOpen = jest.fn();
let event: any;
let explore: PanelMenuItem;
let navigateSpy: any;
beforeAll(() => { const menuItems = getPanelMenu(dashboard, panel, LoadingState.Loading);
const panel = new PanelModel({}); expect(menuItems).toEqual(
const dashboard = createDashboardModelFixture({}); expect.arrayContaining([
const menuItems = getPanelMenu(dashboard, panel); expect.objectContaining({
explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem; iconClassName: 'circle',
navigateSpy = jest.spyOn(actions, 'navigateToExplore'); text: 'Stop query',
window.open = windowOpen; }),
])
event = { );
ctrlKey: true, });
preventDefault: jest.fn(), });
};
describe('when panel is in view mode', () => {
setStore({ dispatch: jest.fn() } as any); it('should return the correct panel menu items', () => {
}); const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }];
const ctrl: any = { getExtendedMenu };
it('should navigate to url without subUrl', () => { const scope: any = { $$childHead: { ctrl } };
explore.onClick!(event); const angularComponent: any = { getScope: () => scope };
const panel = new PanelModel({ isViewing: true });
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; const dashboard = createDashboardModelFixture({});
openInNewWindow(testUrl); const menuItems = getPanelMenu(dashboard, panel, undefined, angularComponent);
expect(menuItems).toMatchInlineSnapshot(`
expect(windowOpen).toHaveBeenLastCalledWith(testUrl); [
}); {
"iconClassName": "eye",
it('should navigate to url with subUrl', () => { "onClick": [Function],
config.appSubUrl = testSubUrl; "shortcut": "v",
explore.onClick!(event); "text": "View",
},
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow; {
"iconClassName": "edit",
openInNewWindow(testUrl); "onClick": [Function],
"shortcut": "e",
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`); "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}`);
}); });
}); });

View File

@ -1,5 +1,6 @@
import { PanelMenuItem } from '@grafana/data'; import { PanelMenuItem } from '@grafana/data';
import { AngularComponent, getDataSourceSrv, locationService, reportInteraction } from '@grafana/runtime'; import { AngularComponent, getDataSourceSrv, locationService, reportInteraction } from '@grafana/runtime';
import { LoadingState } from '@grafana/schema';
import { PanelCtrl } from 'app/angular/panel/panel_ctrl'; import { PanelCtrl } from 'app/angular/panel/panel_ctrl';
import config from 'app/core/config'; import config from 'app/core/config';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
@ -26,6 +27,7 @@ import { getTimeSrv } from '../services/TimeSrv';
export function getPanelMenu( export function getPanelMenu(
dashboard: DashboardModel, dashboard: DashboardModel,
panel: PanelModel, panel: PanelModel,
loadingState?: LoadingState,
angularComponent?: AngularComponent | null angularComponent?: AngularComponent | null
): PanelMenuItem[] { ): PanelMenuItem[] {
const onViewPanel = (event: React.MouseEvent<any>) => { const onViewPanel = (event: React.MouseEvent<any>) => {
@ -98,6 +100,12 @@ export function getPanelMenu(
event.preventDefault(); event.preventDefault();
toggleLegend(panel); toggleLegend(panel);
}; };
const onCancelStreaming = (event: React.MouseEvent) => {
event.preventDefault();
panel.getQueryRunner().cancelQuery();
};
const menu: PanelMenuItem[] = []; const menu: PanelMenuItem[] = [];
if (!panel.isEditing) { 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`); const shareTextTranslation = t('panel.header-menu.share', `Share`);
menu.push({ menu.push({