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:
parent
f85a948214
commit
84eb275c8d
@ -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);
|
}),
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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),
|
||||||
|
@ -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',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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 });
|
||||||
};
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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({
|
||||||
|
Loading…
Reference in New Issue
Block a user