mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
GraphNG: Context menu (#29745)
* Refactor Context menu and add Menu component to grafana/ui * ContextMenuPlugin WIP * Fix docs issues * Remove Add annotations menu item from graph context menu * ts ifx
This commit is contained in:
parent
92527c2647
commit
69b05aae46
@ -1,27 +1,7 @@
|
|||||||
import React, { useRef, useState, useLayoutEffect } from 'react';
|
import React, { useRef, useState, useLayoutEffect } from 'react';
|
||||||
import { css, cx } from 'emotion';
|
import { useClickAway } from 'react-use';
|
||||||
import useClickAway from 'react-use/lib/useClickAway';
|
import { Portal } from '../Portal/Portal';
|
||||||
import { useTheme } from '../../index';
|
import { Menu, MenuItemsGroup } from '../Menu/Menu';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
|
||||||
import { stylesFactory } from '../../themes/stylesFactory';
|
|
||||||
import { Portal, List } from '../index';
|
|
||||||
import { Icon } from '../Icon/Icon';
|
|
||||||
import { IconName } from '../../types';
|
|
||||||
import { LinkTarget } from '@grafana/data';
|
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
|
||||||
label: string;
|
|
||||||
target?: LinkTarget;
|
|
||||||
icon?: string;
|
|
||||||
url?: string;
|
|
||||||
onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;
|
|
||||||
group?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMenuGroup {
|
|
||||||
label?: string;
|
|
||||||
items: ContextMenuItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMenuProps {
|
export interface ContextMenuProps {
|
||||||
/** Starting horizontal position for the menu */
|
/** Starting horizontal position for the menu */
|
||||||
@ -29,93 +9,14 @@ export interface ContextMenuProps {
|
|||||||
/** Starting vertical position for the menu */
|
/** Starting vertical position for the menu */
|
||||||
y: number;
|
y: number;
|
||||||
/** Callback for closing the menu */
|
/** Callback for closing the menu */
|
||||||
onClose: () => void;
|
onClose?: () => void;
|
||||||
/** List of the menu items to display */
|
/** List of the menu items to display */
|
||||||
items?: ContextMenuGroup[];
|
items?: MenuItemsGroup[];
|
||||||
/** A function that returns header element */
|
/** A function that returns header element */
|
||||||
renderHeader?: () => React.ReactNode;
|
renderHeader?: () => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getContextMenuStyles = stylesFactory((theme: GrafanaTheme) => {
|
|
||||||
const { white, black, dark1, dark2, dark7, gray1, gray3, gray5, gray7 } = theme.palette;
|
|
||||||
const lightThemeStyles = {
|
|
||||||
linkColor: dark2,
|
|
||||||
linkColorHover: theme.colors.link,
|
|
||||||
wrapperBg: gray7,
|
|
||||||
wrapperShadow: gray3,
|
|
||||||
itemColor: black,
|
|
||||||
groupLabelColor: gray1,
|
|
||||||
itemBgHover: gray5,
|
|
||||||
headerBg: white,
|
|
||||||
headerSeparator: white,
|
|
||||||
};
|
|
||||||
const darkThemeStyles = {
|
|
||||||
linkColor: theme.colors.text,
|
|
||||||
linkColorHover: white,
|
|
||||||
wrapperBg: dark2,
|
|
||||||
wrapperShadow: black,
|
|
||||||
itemColor: white,
|
|
||||||
groupLabelColor: theme.colors.textWeak,
|
|
||||||
itemBgHover: dark7,
|
|
||||||
headerBg: dark1,
|
|
||||||
headerSeparator: dark7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = theme.isDark ? darkThemeStyles : lightThemeStyles;
|
|
||||||
|
|
||||||
return {
|
|
||||||
header: css`
|
|
||||||
padding: 4px;
|
|
||||||
border-bottom: 1px solid ${styles.headerSeparator};
|
|
||||||
background: ${styles.headerBg};
|
|
||||||
margin-bottom: ${theme.spacing.xs};
|
|
||||||
border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0;
|
|
||||||
`,
|
|
||||||
wrapper: css`
|
|
||||||
background: ${styles.wrapperBg};
|
|
||||||
z-index: 1;
|
|
||||||
box-shadow: 0 2px 5px 0 ${styles.wrapperShadow};
|
|
||||||
min-width: 200px;
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: ${theme.border.radius.sm};
|
|
||||||
`,
|
|
||||||
link: css`
|
|
||||||
color: ${styles.linkColor};
|
|
||||||
display: flex;
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
color: ${styles.linkColorHover};
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
item: css`
|
|
||||||
background: none;
|
|
||||||
padding: 4px 8px;
|
|
||||||
color: ${styles.itemColor};
|
|
||||||
border-left: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
background: ${styles.itemBgHover};
|
|
||||||
border-image: linear-gradient(#f05a28 30%, #fbca0a 99%);
|
|
||||||
border-image-slice: 1;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
groupLabel: css`
|
|
||||||
color: ${styles.groupLabelColor};
|
|
||||||
font-size: ${theme.typography.size.sm};
|
|
||||||
line-height: ${theme.typography.lineHeight.md};
|
|
||||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
|
||||||
`,
|
|
||||||
icon: css`
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-right: 10px;
|
|
||||||
color: ${theme.colors.linkDisabled};
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClose, items, renderHeader }) => {
|
export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClose, items, renderHeader }) => {
|
||||||
const theme = useTheme();
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [positionStyles, setPositionStyles] = useState({});
|
const [positionStyles, setPositionStyles] = useState({});
|
||||||
|
|
||||||
@ -143,96 +44,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const styles = getContextMenuStyles(theme);
|
|
||||||
const header = renderHeader && renderHeader();
|
const header = renderHeader && renderHeader();
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div ref={menuRef} style={positionStyles} className={styles.wrapper}>
|
<Menu header={header} items={items} onClose={onClose} ref={menuRef} style={positionStyles} />
|
||||||
{header && <div className={styles.header}>{header}</div>}
|
|
||||||
<List
|
|
||||||
items={items || []}
|
|
||||||
renderItem={(item, index) => {
|
|
||||||
return <ContextMenuGroupComponent group={item} onClick={onClose} />;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
</Portal>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ContextMenuItemProps {
|
|
||||||
label: string;
|
|
||||||
icon?: string;
|
|
||||||
url?: string;
|
|
||||||
target?: string;
|
|
||||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContextMenuItemComponent: React.FC<ContextMenuItemProps> = React.memo(
|
|
||||||
({ url, icon, label, target, onClick, className }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const styles = getContextMenuStyles(theme);
|
|
||||||
return (
|
|
||||||
<div className={styles.item}>
|
|
||||||
<a
|
|
||||||
href={url ? url : undefined}
|
|
||||||
target={target}
|
|
||||||
className={cx(className, styles.link)}
|
|
||||||
onClick={e => {
|
|
||||||
if (onClick) {
|
|
||||||
onClick(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon && <Icon name={icon as IconName} className={styles.icon} />} {label}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
ContextMenuItemComponent.displayName = 'ContextMenuItemComponent';
|
|
||||||
|
|
||||||
interface ContextMenuGroupProps {
|
|
||||||
group: ContextMenuGroup;
|
|
||||||
onClick?: () => void; // Used with 'onClose'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContextMenuGroupComponent: React.FC<ContextMenuGroupProps> = ({ group, onClick }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const styles = getContextMenuStyles(theme);
|
|
||||||
|
|
||||||
if (group.items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{group.label && <div className={styles.groupLabel}>{group.label}</div>}
|
|
||||||
<List
|
|
||||||
items={group.items || []}
|
|
||||||
renderItem={item => {
|
|
||||||
return (
|
|
||||||
<ContextMenuItemComponent
|
|
||||||
url={item.url}
|
|
||||||
label={item.label}
|
|
||||||
target={item.target}
|
|
||||||
icon={item.icon}
|
|
||||||
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
if (item.onClick) {
|
|
||||||
item.onClick(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typically closes the context menu
|
|
||||||
if (onClick) {
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
ContextMenu.displayName = 'ContextMenu';
|
ContextMenu.displayName = 'ContextMenu';
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ContextMenu, ContextMenuGroup } from '../ContextMenu/ContextMenu';
|
import { ContextMenu } from '../ContextMenu/ContextMenu';
|
||||||
|
import { MenuItemsGroup } from '../Menu/Menu';
|
||||||
|
|
||||||
interface WithContextMenuProps {
|
interface WithContextMenuProps {
|
||||||
/** Menu item trigger that accepts openMenu prop */
|
/** Menu item trigger that accepts openMenu prop */
|
||||||
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
|
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
|
||||||
/** A function that returns an array of menu items */
|
/** A function that returns an array of menu items */
|
||||||
getContextMenuItems: () => ContextMenuGroup[];
|
getContextMenuItems: () => MenuItemsGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getContextMenuItems }) => {
|
export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getContextMenuItems }) => {
|
||||||
|
@ -2,9 +2,9 @@ import React, { FC, CSSProperties, HTMLProps } from 'react';
|
|||||||
import { FormattedValue } from '@grafana/data';
|
import { FormattedValue } from '@grafana/data';
|
||||||
|
|
||||||
export interface Props extends Omit<HTMLProps<HTMLDivElement>, 'className' | 'value' | 'style'> {
|
export interface Props extends Omit<HTMLProps<HTMLDivElement>, 'className' | 'value' | 'style'> {
|
||||||
className?: string;
|
|
||||||
value: FormattedValue;
|
value: FormattedValue;
|
||||||
style: CSSProperties;
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fontSizeReductionFactor(fontSize: number) {
|
function fontSizeReductionFactor(fontSize: number) {
|
||||||
@ -18,17 +18,22 @@ function fontSizeReductionFactor(fontSize: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FormattedValueDisplay: FC<Props> = ({ value, className, style, ...htmlProps }) => {
|
export const FormattedValueDisplay: FC<Props> = ({ value, className, style, ...htmlProps }) => {
|
||||||
const fontSize = style.fontSize as number;
|
|
||||||
const reductionFactor = fontSizeReductionFactor(fontSize);
|
|
||||||
const hasPrefix = (value.prefix ?? '').length > 0;
|
const hasPrefix = (value.prefix ?? '').length > 0;
|
||||||
const hasSuffix = (value.suffix ?? '').length > 0;
|
const hasSuffix = (value.suffix ?? '').length > 0;
|
||||||
|
let suffixStyle;
|
||||||
|
|
||||||
|
if (style && style.fontSize) {
|
||||||
|
const fontSize = style?.fontSize as number;
|
||||||
|
const reductionFactor = fontSizeReductionFactor(fontSize);
|
||||||
|
suffixStyle = { fontSize: fontSize * reductionFactor };
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={style} {...htmlProps}>
|
<div className={className} style={style} {...htmlProps}>
|
||||||
<div>
|
<div>
|
||||||
{hasPrefix && <span>{value.prefix}</span>}
|
{hasPrefix && <span>{value.prefix}</span>}
|
||||||
<span>{value.text}</span>
|
<span>{value.text}</span>
|
||||||
{hasSuffix && <span style={{ fontSize: fontSize * reductionFactor }}>{value.suffix}</span>}
|
{hasSuffix && <span style={suffixStyle}>{value.suffix}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { ContextMenu, ContextMenuProps } from '../ContextMenu/ContextMenu';
|
import { ContextMenu, ContextMenuProps } from '../ContextMenu/ContextMenu';
|
||||||
import { ThemeContext } from '../../themes';
|
|
||||||
import { SeriesIcon } from '../Legend/SeriesIcon';
|
|
||||||
import { GraphDimensions } from './GraphTooltip/types';
|
import { GraphDimensions } from './GraphTooltip/types';
|
||||||
import {
|
import {
|
||||||
FlotDataPoint,
|
FlotDataPoint,
|
||||||
getValueFromDimension,
|
getValueFromDimension,
|
||||||
getDisplayProcessor,
|
getDisplayProcessor,
|
||||||
formattedValueToString,
|
|
||||||
Dimensions,
|
Dimensions,
|
||||||
dateTimeFormat,
|
dateTimeFormat,
|
||||||
TimeZone,
|
TimeZone,
|
||||||
|
FormattedValue,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
import { useTheme } from '../../themes';
|
||||||
|
import { HorizontalGroup } from '../Layout/Layout';
|
||||||
|
import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay';
|
||||||
|
import { SeriesIcon } from '../Legend/SeriesIcon';
|
||||||
import { css } from 'emotion';
|
import { css } from 'emotion';
|
||||||
|
|
||||||
export type ContextDimensions<T extends Dimensions = any> = { [key in keyof T]: [number, number | undefined] | null };
|
export type ContextDimensions<T extends Dimensions = any> = { [key in keyof T]: [number, number | undefined] | null };
|
||||||
@ -23,6 +25,7 @@ export type GraphContextMenuProps = ContextMenuProps & {
|
|||||||
contextDimensions?: ContextDimensions;
|
contextDimensions?: ContextDimensions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||||
getContextMenuSource,
|
getContextMenuSource,
|
||||||
timeZone,
|
timeZone,
|
||||||
@ -31,7 +34,6 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
|||||||
contextDimensions,
|
contextDimensions,
|
||||||
...otherProps
|
...otherProps
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useContext(ThemeContext);
|
|
||||||
const source = getContextMenuSource();
|
const source = getContextMenuSource();
|
||||||
|
|
||||||
// Do not render items that do not have label specified
|
// Do not render items that do not have label specified
|
||||||
@ -70,38 +72,55 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<GraphContextMenuHeader
|
||||||
className={css`
|
timestamp={formattedValue}
|
||||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
seriesColor={source.series.color}
|
||||||
font-size: ${theme.typography.size.sm};
|
displayName={source.series.alias || source.series.label}
|
||||||
z-index: ${theme.zIndex.tooltip};
|
displayValue={value}
|
||||||
`}
|
/>
|
||||||
>
|
);
|
||||||
<strong>{formattedValue}</strong>
|
};
|
||||||
|
|
||||||
|
return <ContextMenu {...otherProps} items={itemsToRender} renderHeader={renderHeader} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export const GraphContextMenuHeader = ({
|
||||||
|
timestamp,
|
||||||
|
seriesColor,
|
||||||
|
displayName,
|
||||||
|
displayValue,
|
||||||
|
}: {
|
||||||
|
timestamp: string;
|
||||||
|
seriesColor: string;
|
||||||
|
displayName: string;
|
||||||
|
displayValue: FormattedValue;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||||
|
font-size: ${theme.typography.size.sm};
|
||||||
|
z-index: ${theme.zIndex.tooltip};
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<strong>{timestamp}</strong>
|
||||||
|
<HorizontalGroup>
|
||||||
<div>
|
<div>
|
||||||
<SeriesIcon color={source.series.color} />
|
<SeriesIcon color={seriesColor} />
|
||||||
<span
|
<span
|
||||||
className={css`
|
className={css`
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-left: ${theme.spacing.xs};
|
padding-left: ${theme.spacing.xs};
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{source.series.alias || source.series.label}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
{value && (
|
|
||||||
<span
|
|
||||||
className={css`
|
|
||||||
white-space: nowrap;
|
|
||||||
padding-left: ${theme.spacing.md};
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{formattedValueToString(value)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{displayValue && <FormattedValueDisplay value={displayValue} />}
|
||||||
);
|
</HorizontalGroup>
|
||||||
};
|
</div>
|
||||||
|
);
|
||||||
return <ContextMenu {...otherProps} items={itemsToRender} renderHeader={renderHeader} />;
|
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Story } from '@storybook/react';
|
||||||
|
import { Menu, MenuProps } from './Menu';
|
||||||
|
import { GraphContextMenuHeader } from '..';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'General/Menu',
|
||||||
|
component: Menu,
|
||||||
|
argTypes: {
|
||||||
|
items: { control: { disable: true } },
|
||||||
|
header: { control: { disable: true } },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
knobs: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Simple: Story<MenuProps> = args => (
|
||||||
|
<div>
|
||||||
|
<Menu {...args} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
Simple.args = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Group 1',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Menu item 1',
|
||||||
|
icon: 'history',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Menu item 2',
|
||||||
|
icon: 'filter',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Group 2',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Menu item 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Menu item 2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
header: (
|
||||||
|
<GraphContextMenuHeader
|
||||||
|
timestamp="2020-11-25 19:04:25"
|
||||||
|
seriesColor="#00ff00"
|
||||||
|
displayName="A-series"
|
||||||
|
displayValue={{
|
||||||
|
text: '128',
|
||||||
|
suffix: 'km/h',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
211
packages/grafana-ui/src/components/Menu/Menu.tsx
Normal file
211
packages/grafana-ui/src/components/Menu/Menu.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
import { GrafanaTheme, LinkTarget } from '@grafana/data';
|
||||||
|
import { List } from '../List/List';
|
||||||
|
import { useStyles } from '../../themes';
|
||||||
|
import { Icon } from '../Icon/Icon';
|
||||||
|
import { IconName } from '../../types';
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
/** Label of the menu item */
|
||||||
|
label: string;
|
||||||
|
/** Target of the menu item (i.e. new window) */
|
||||||
|
target?: LinkTarget;
|
||||||
|
/** Icon of the menu item */
|
||||||
|
icon?: IconName;
|
||||||
|
/** Url of the menu item */
|
||||||
|
url?: string;
|
||||||
|
/** Handler for the click behaviour */
|
||||||
|
onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;
|
||||||
|
/** Handler for the click behaviour */
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
export interface MenuItemsGroup {
|
||||||
|
/** Label for the menu items group */
|
||||||
|
label?: string;
|
||||||
|
/** Items of the group */
|
||||||
|
items: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** React element rendered at the top of the menu */
|
||||||
|
header?: React.ReactNode;
|
||||||
|
/** Array of menu items */
|
||||||
|
items?: MenuItemsGroup[];
|
||||||
|
/** Callback performed when menu is closed */
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(({ header, items, onClose, ...otherProps }, ref) => {
|
||||||
|
const styles = useStyles(getMenuStyles);
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...otherProps} ref={ref} className={styles.wrapper}>
|
||||||
|
{header && <div className={styles.header}>{header}</div>}
|
||||||
|
<List
|
||||||
|
items={items || []}
|
||||||
|
renderItem={item => {
|
||||||
|
return <MenuGroup group={item} onClick={onClick} />;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Menu.displayName = 'Menu';
|
||||||
|
|
||||||
|
interface MenuGroupProps {
|
||||||
|
group: MenuItemsGroup;
|
||||||
|
onClick?: () => void; // Used with 'onClose'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuGroup: React.FC<MenuGroupProps> = ({ group, onClick }) => {
|
||||||
|
const styles = useStyles(getMenuStyles);
|
||||||
|
|
||||||
|
if (group.items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{group.label && <div className={styles.groupLabel}>{group.label}</div>}
|
||||||
|
<List
|
||||||
|
items={group.items || []}
|
||||||
|
renderItem={item => {
|
||||||
|
return (
|
||||||
|
<MenuItemComponent
|
||||||
|
url={item.url}
|
||||||
|
label={item.label}
|
||||||
|
target={item.target}
|
||||||
|
icon={item.icon}
|
||||||
|
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
if (item.onClick) {
|
||||||
|
item.onClick(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typically closes the context menu
|
||||||
|
if (onClick) {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
MenuGroup.displayName = 'MenuGroup';
|
||||||
|
|
||||||
|
interface MenuItemProps {
|
||||||
|
label: string;
|
||||||
|
icon?: IconName;
|
||||||
|
url?: string;
|
||||||
|
target?: string;
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuItemComponent: React.FC<MenuItemProps> = React.memo(({ url, icon, label, target, onClick, className }) => {
|
||||||
|
const styles = useStyles(getMenuStyles);
|
||||||
|
return (
|
||||||
|
<div className={styles.item}>
|
||||||
|
<a
|
||||||
|
href={url ? url : undefined}
|
||||||
|
target={target}
|
||||||
|
className={cx(className, styles.link)}
|
||||||
|
onClick={e => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon && <Icon name={icon} className={styles.icon} />} {label}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
MenuItemComponent.displayName = 'MenuItemComponent';
|
||||||
|
|
||||||
|
const getMenuStyles = (theme: GrafanaTheme) => {
|
||||||
|
const { white, black, dark1, dark2, dark7, gray1, gray3, gray5, gray7 } = theme.palette;
|
||||||
|
const lightThemeStyles = {
|
||||||
|
linkColor: dark2,
|
||||||
|
linkColorHover: theme.colors.link,
|
||||||
|
wrapperBg: gray7,
|
||||||
|
wrapperShadow: gray3,
|
||||||
|
itemColor: black,
|
||||||
|
groupLabelColor: gray1,
|
||||||
|
itemBgHover: gray5,
|
||||||
|
headerBg: white,
|
||||||
|
headerSeparator: white,
|
||||||
|
};
|
||||||
|
const darkThemeStyles = {
|
||||||
|
linkColor: theme.colors.text,
|
||||||
|
linkColorHover: white,
|
||||||
|
wrapperBg: dark2,
|
||||||
|
wrapperShadow: black,
|
||||||
|
itemColor: white,
|
||||||
|
groupLabelColor: theme.colors.textWeak,
|
||||||
|
itemBgHover: dark7,
|
||||||
|
headerBg: dark1,
|
||||||
|
headerSeparator: dark7,
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = theme.isDark ? darkThemeStyles : lightThemeStyles;
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: css`
|
||||||
|
padding: 4px;
|
||||||
|
border-bottom: 1px solid ${styles.headerSeparator};
|
||||||
|
background: ${styles.headerBg};
|
||||||
|
margin-bottom: ${theme.spacing.xs};
|
||||||
|
border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0;
|
||||||
|
`,
|
||||||
|
wrapper: css`
|
||||||
|
background: ${styles.wrapperBg};
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 2px 5px 0 ${styles.wrapperShadow};
|
||||||
|
min-width: 200px;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: ${theme.border.radius.sm};
|
||||||
|
`,
|
||||||
|
link: css`
|
||||||
|
color: ${styles.linkColor};
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
color: ${styles.linkColorHover};
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
item: css`
|
||||||
|
background: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: ${styles.itemColor};
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
background: ${styles.itemBgHover};
|
||||||
|
border-image: linear-gradient(#f05a28 30%, #fbca0a 99%);
|
||||||
|
border-image-slice: 1;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
groupLabel: css`
|
||||||
|
color: ${styles.groupLabelColor};
|
||||||
|
font-size: ${theme.typography.size.sm};
|
||||||
|
line-height: ${theme.typography.lineHeight.md};
|
||||||
|
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||||
|
`,
|
||||||
|
icon: css`
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-right: 10px;
|
||||||
|
color: ${theme.colors.linkDisabled};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -72,7 +72,7 @@ export { Gauge } from './Gauge/Gauge';
|
|||||||
export { Graph } from './Graph/Graph';
|
export { Graph } from './Graph/Graph';
|
||||||
export { GraphLegend } from './Graph/GraphLegend';
|
export { GraphLegend } from './Graph/GraphLegend';
|
||||||
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
||||||
export { GraphContextMenu } from './Graph/GraphContextMenu';
|
export { GraphContextMenu, GraphContextMenuHeader } from './Graph/GraphContextMenu';
|
||||||
export { BarGauge, BarGaugeDisplayMode } from './BarGauge/BarGauge';
|
export { BarGauge, BarGaugeDisplayMode } from './BarGauge/BarGauge';
|
||||||
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
|
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
|
||||||
export { VizRepeater, VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater';
|
export { VizRepeater, VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater';
|
||||||
@ -104,7 +104,8 @@ export { FullWidthButtonContainer } from './Button/FullWidthButtonContainer';
|
|||||||
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
||||||
export * from './SingleStatShared/index';
|
export * from './SingleStatShared/index';
|
||||||
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
||||||
export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu';
|
export { ContextMenu, ContextMenuProps } from './ContextMenu/ContextMenu';
|
||||||
|
export { Menu, MenuItem, MenuItemsGroup } from './Menu/Menu';
|
||||||
export { WithContextMenu } from './ContextMenu/WithContextMenu';
|
export { WithContextMenu } from './ContextMenu/WithContextMenu';
|
||||||
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
||||||
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
||||||
@ -174,6 +175,7 @@ export { FileUpload } from './FileUpload/FileUpload';
|
|||||||
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
|
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
|
||||||
export { Card, Props as CardProps, ContainerProps, CardInnerProps, getCardStyles } from './Card/Card';
|
export { Card, Props as CardProps, ContainerProps, CardInnerProps, getCardStyles } from './Card/Card';
|
||||||
|
|
||||||
|
export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDisplay';
|
||||||
// Legacy forms
|
// Legacy forms
|
||||||
|
|
||||||
// Export this until we've figured out a good approach to inline form styles.
|
// Export this until we've figured out a good approach to inline form styles.
|
||||||
|
@ -1,72 +0,0 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react';
|
|
||||||
import { ClickPlugin } from './ClickPlugin';
|
|
||||||
import { Portal } from '../../Portal/Portal';
|
|
||||||
import { css } from 'emotion';
|
|
||||||
import useClickAway from 'react-use/lib/useClickAway';
|
|
||||||
|
|
||||||
interface ContextMenuPluginProps {
|
|
||||||
onOpen?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @alpha
|
|
||||||
*/
|
|
||||||
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({ onClose }) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
}, [setIsOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClickPlugin id="ContextMenu" onClick={onClick}>
|
|
||||||
{({ point, coords, clearSelection }) => {
|
|
||||||
return (
|
|
||||||
<Portal>
|
|
||||||
<ContextMenu
|
|
||||||
selection={{ point, coords }}
|
|
||||||
onClose={() => {
|
|
||||||
clearSelection();
|
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</ClickPlugin>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ContextMenuProps {
|
|
||||||
onClose?: () => void;
|
|
||||||
selection: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContextMenu: React.FC<ContextMenuProps> = ({ onClose, selection }) => {
|
|
||||||
const ref = useRef(null);
|
|
||||||
|
|
||||||
useClickAway(ref, () => {
|
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={css`
|
|
||||||
background: yellow;
|
|
||||||
position: absolute;
|
|
||||||
// rendering in Portal, hence using viewport coords
|
|
||||||
top: ${selection.coords.viewport.y + 10}px;
|
|
||||||
left: ${selection.coords.viewport.x + 10}px;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
Point: {JSON.stringify(selection.point)} <br />
|
|
||||||
Viewport coords: {JSON.stringify(selection.coords.viewport)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -2,5 +2,4 @@ export { ClickPlugin } from './ClickPlugin';
|
|||||||
export { SelectionPlugin } from './SelectionPlugin';
|
export { SelectionPlugin } from './SelectionPlugin';
|
||||||
export { ZoomPlugin } from './ZoomPlugin';
|
export { ZoomPlugin } from './ZoomPlugin';
|
||||||
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
|
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
|
||||||
export { ContextMenuPlugin } from './ContextMenuPlugin';
|
|
||||||
export { TooltipPlugin } from './TooltipPlugin';
|
export { TooltipPlugin } from './TooltipPlugin';
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
import { ContextMenuItem } from '../components/ContextMenu/ContextMenu';
|
|
||||||
import { LinkModel } from '@grafana/data';
|
import { LinkModel } from '@grafana/data';
|
||||||
|
import { MenuItem } from '../components/Menu/Menu';
|
||||||
|
import { IconName } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delays creating links until we need to open the ContextMenu
|
* Delays creating links until we need to open the ContextMenu
|
||||||
*/
|
*/
|
||||||
export const linkModelToContextMenuItems: (links: () => LinkModel[]) => ContextMenuItem[] = links => {
|
export const linkModelToContextMenuItems: (links: () => LinkModel[]) => MenuItem[] = links => {
|
||||||
return links().map(link => {
|
return links().map(link => {
|
||||||
return {
|
return {
|
||||||
label: link.title,
|
label: link.title,
|
||||||
// TODO: rename to href
|
// TODO: rename to href
|
||||||
url: link.href,
|
url: link.href,
|
||||||
target: link.target,
|
target: link.target,
|
||||||
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}`,
|
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||||
onClick: link.onClick,
|
onClick: link.onClick,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { ContextMenuItem } from '@grafana/ui';
|
import { MenuItem } from '@grafana/ui';
|
||||||
import { FlotDataPoint } from '@grafana/data';
|
import { FlotDataPoint } from '@grafana/data';
|
||||||
|
|
||||||
export class GraphContextMenuCtrl {
|
export class GraphContextMenuCtrl {
|
||||||
private source?: FlotDataPoint | null;
|
private source?: FlotDataPoint | null;
|
||||||
private scope?: any;
|
private scope?: any;
|
||||||
menuItemsSupplier?: () => ContextMenuItem[];
|
menuItemsSupplier?: () => MenuItem[];
|
||||||
scrollContextElement: HTMLElement | null;
|
scrollContextElement: HTMLElement | null;
|
||||||
position: {
|
position: {
|
||||||
x: number;
|
x: number;
|
||||||
@ -61,7 +61,7 @@ export class GraphContextMenuCtrl {
|
|||||||
return this.source;
|
return this.source;
|
||||||
};
|
};
|
||||||
|
|
||||||
setMenuItemsSupplier = (menuItemsSupplier: () => ContextMenuItem[]) => {
|
setMenuItemsSupplier = (menuItemsSupplier: () => MenuItem[]) => {
|
||||||
this.menuItemsSupplier = menuItemsSupplier;
|
this.menuItemsSupplier = menuItemsSupplier;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ import ReactDOM from 'react-dom';
|
|||||||
import { GraphLegendProps, Legend } from './Legend/Legend';
|
import { GraphLegendProps, Legend } from './Legend/Legend';
|
||||||
|
|
||||||
import { GraphCtrl } from './module';
|
import { GraphCtrl } from './module';
|
||||||
import { ContextMenuGroup, ContextMenuItem, graphTimeFormat, graphTickFormatter } from '@grafana/ui';
|
import { MenuItem, MenuItemsGroup, graphTimeFormat, graphTickFormatter, IconName } from '@grafana/ui';
|
||||||
import { getCurrentTheme, provideTheme } from 'app/core/utils/ConfigProvider';
|
import { getCurrentTheme, provideTheme } from 'app/core/utils/ConfigProvider';
|
||||||
import {
|
import {
|
||||||
DataFrame,
|
DataFrame,
|
||||||
@ -197,10 +197,10 @@ class GraphElement {
|
|||||||
getContextMenuItemsSupplier = (
|
getContextMenuItemsSupplier = (
|
||||||
flotPosition: { x: number; y: number },
|
flotPosition: { x: number; y: number },
|
||||||
linksSupplier?: LinkModelSupplier<FieldDisplay>
|
linksSupplier?: LinkModelSupplier<FieldDisplay>
|
||||||
): (() => ContextMenuGroup[]) => {
|
): (() => MenuItemsGroup[]) => {
|
||||||
return () => {
|
return () => {
|
||||||
// Fixed context menu items
|
// Fixed context menu items
|
||||||
const items: ContextMenuGroup[] = [
|
const items: MenuItemsGroup[] = [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -218,12 +218,12 @@ class GraphElement {
|
|||||||
|
|
||||||
const dataLinks = [
|
const dataLinks = [
|
||||||
{
|
{
|
||||||
items: linksSupplier.getLinks(this.panel.scopedVars).map<ContextMenuItem>(link => {
|
items: linksSupplier.getLinks(this.panel.scopedVars).map<MenuItem>(link => {
|
||||||
return {
|
return {
|
||||||
label: link.title,
|
label: link.title,
|
||||||
url: link.href,
|
url: link.href,
|
||||||
target: link.target,
|
target: link.target,
|
||||||
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}`,
|
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||||
onClick: link.onClick,
|
onClick: link.onClick,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ContextMenuPlugin, TooltipPlugin, ZoomPlugin, GraphNG } from '@grafana/ui';
|
import { TooltipPlugin, ZoomPlugin, GraphNG } from '@grafana/ui';
|
||||||
import { PanelProps } from '@grafana/data';
|
import { PanelProps } from '@grafana/data';
|
||||||
import { Options } from './types';
|
import { Options } from './types';
|
||||||
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
||||||
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
||||||
|
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
|
||||||
|
|
||||||
interface GraphPanelProps extends PanelProps<Options> {}
|
interface GraphPanelProps extends PanelProps<Options> {}
|
||||||
|
|
||||||
@ -27,7 +28,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
|||||||
>
|
>
|
||||||
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
||||||
<ZoomPlugin onZoom={onChangeTimeRange} />
|
<ZoomPlugin onZoom={onChangeTimeRange} />
|
||||||
<ContextMenuPlugin />
|
<ContextMenuPlugin timeZone={timeZone} />
|
||||||
{data.annotations ? <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} /> : <></>}
|
{data.annotations ? <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} /> : <></>}
|
||||||
{data.annotations ? <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} /> : <></>}
|
{data.annotations ? <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} /> : <></>}
|
||||||
</GraphNG>
|
</GraphNG>
|
||||||
|
143
public/app/plugins/panel/graph3/plugins/ContextMenuPlugin.tsx
Normal file
143
public/app/plugins/panel/graph3/plugins/ContextMenuPlugin.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
ClickPlugin,
|
||||||
|
ContextMenu,
|
||||||
|
GraphContextMenuHeader,
|
||||||
|
IconName,
|
||||||
|
MenuItem,
|
||||||
|
MenuItemsGroup,
|
||||||
|
Portal,
|
||||||
|
usePlotData,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
import { DataFrameView, DisplayValue, Field, getDisplayProcessor, getFieldDisplayName } from '@grafana/data';
|
||||||
|
import { TimeZone } from '@grafana/data';
|
||||||
|
import { useClickAway } from 'react-use';
|
||||||
|
import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers';
|
||||||
|
|
||||||
|
interface ContextMenuPluginProps {
|
||||||
|
defaultItems?: MenuItemsGroup[];
|
||||||
|
timeZone: TimeZone;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({ onClose, timeZone, defaultItems }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}, [setIsOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClickPlugin id="ContextMenu" onClick={onClick}>
|
||||||
|
{({ point, coords, clearSelection }) => {
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<ContextMenuView
|
||||||
|
defaultItems={defaultItems}
|
||||||
|
timeZone={timeZone}
|
||||||
|
selection={{ point, coords }}
|
||||||
|
onClose={() => {
|
||||||
|
clearSelection();
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ClickPlugin>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
defaultItems?: MenuItemsGroup[];
|
||||||
|
timeZone: TimeZone;
|
||||||
|
onClose?: () => void;
|
||||||
|
selection: {
|
||||||
|
point: { seriesIdx: number | null; dataIdx: number | null };
|
||||||
|
coords: { plotCanvas: { x: number; y: number }; viewport: { x: number; y: number } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMenuView: React.FC<ContextMenuProps> = ({ selection, timeZone, defaultItems, ...otherProps }) => {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const { data } = usePlotData();
|
||||||
|
const { seriesIdx, dataIdx } = selection.point;
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
if (otherProps.onClose) {
|
||||||
|
otherProps.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useClickAway(ref, () => {
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextMenuProps = useMemo(() => {
|
||||||
|
const items = defaultItems ? [...defaultItems] : [];
|
||||||
|
let field: Field;
|
||||||
|
let displayValue: DisplayValue;
|
||||||
|
const timeField = data.fields[0];
|
||||||
|
const timeFormatter = timeField.display || getDisplayProcessor({ field: timeField, timeZone });
|
||||||
|
let renderHeader: () => JSX.Element | null = () => null;
|
||||||
|
|
||||||
|
if (seriesIdx && dataIdx) {
|
||||||
|
field = data.fields[seriesIdx];
|
||||||
|
displayValue = field.display!(field.values.get(dataIdx));
|
||||||
|
const hasLinks = field.config.links && field.config.links.length > 0;
|
||||||
|
|
||||||
|
if (hasLinks) {
|
||||||
|
const linksSupplier = getFieldLinksSupplier({
|
||||||
|
display: displayValue,
|
||||||
|
name: field.name,
|
||||||
|
view: new DataFrameView(data),
|
||||||
|
rowIndex: dataIdx,
|
||||||
|
colIndex: seriesIdx,
|
||||||
|
field: field.config,
|
||||||
|
hasLinks,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (linksSupplier) {
|
||||||
|
items.push({
|
||||||
|
items: linksSupplier.getLinks(/*this.panel.scopedVars*/).map<MenuItem>(link => {
|
||||||
|
return {
|
||||||
|
label: link.title,
|
||||||
|
url: link.href,
|
||||||
|
target: link.target,
|
||||||
|
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||||
|
onClick: link.onClick,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
renderHeader = () => (
|
||||||
|
<GraphContextMenuHeader
|
||||||
|
timestamp={timeFormatter(timeField.values.get(dataIdx)).text}
|
||||||
|
displayValue={displayValue}
|
||||||
|
seriesColor={displayValue.color!}
|
||||||
|
displayName={getFieldDisplayName(field, data)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderHeader,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}, [defaultItems, seriesIdx, dataIdx, data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu
|
||||||
|
{...contextMenuProps}
|
||||||
|
x={selection.coords.viewport.x}
|
||||||
|
y={selection.coords.viewport.y}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user