Commit 69b05aae by Dominik Prokop Committed by GitHub

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
parent 92527c26
import React, { useRef, useState, useLayoutEffect } from 'react';
import { css, cx } from 'emotion';
import useClickAway from 'react-use/lib/useClickAway';
import { useTheme } from '../../index';
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[];
}
import { useClickAway } from 'react-use';
import { Portal } from '../Portal/Portal';
import { Menu, MenuItemsGroup } from '../Menu/Menu';
export interface ContextMenuProps {
/** Starting horizontal position for the menu */
......@@ -29,93 +9,14 @@ export interface ContextMenuProps {
/** Starting vertical position for the menu */
y: number;
/** Callback for closing the menu */
onClose: () => void;
onClose?: () => void;
/** List of the menu items to display */
items?: ContextMenuGroup[];
items?: MenuItemsGroup[];
/** A function that returns header element */
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 }) => {
const theme = useTheme();
const menuRef = useRef<HTMLDivElement>(null);
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();
return (
<Portal>
<div ref={menuRef} style={positionStyles} className={styles.wrapper}>
{header && <div className={styles.header}>{header}</div>}
<List
items={items || []}
renderItem={(item, index) => {
return <ContextMenuGroupComponent group={item} onClick={onClose} />;
}}
/>
</div>
<Menu header={header} items={items} onClose={onClose} ref={menuRef} style={positionStyles} />
</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';
import React, { useState } from 'react';
import { ContextMenu, ContextMenuGroup } from '../ContextMenu/ContextMenu';
import { ContextMenu } from '../ContextMenu/ContextMenu';
import { MenuItemsGroup } from '../Menu/Menu';
interface WithContextMenuProps {
/** Menu item trigger that accepts openMenu prop */
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
/** A function that returns an array of menu items */
getContextMenuItems: () => ContextMenuGroup[];
getContextMenuItems: () => MenuItemsGroup[];
}
export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getContextMenuItems }) => {
......
......@@ -2,9 +2,9 @@ import React, { FC, CSSProperties, HTMLProps } from 'react';
import { FormattedValue } from '@grafana/data';
export interface Props extends Omit<HTMLProps<HTMLDivElement>, 'className' | 'value' | 'style'> {
className?: string;
value: FormattedValue;
style: CSSProperties;
className?: string;
style?: CSSProperties;
}
function fontSizeReductionFactor(fontSize: number) {
......@@ -18,17 +18,22 @@ function fontSizeReductionFactor(fontSize: number) {
}
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 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 (
<div className={className} style={style} {...htmlProps}>
<div>
{hasPrefix && <span>{value.prefix}</span>}
<span>{value.text}</span>
{hasSuffix && <span style={{ fontSize: fontSize * reductionFactor }}>{value.suffix}</span>}
{hasSuffix && <span style={suffixStyle}>{value.suffix}</span>}
</div>
</div>
);
......
import React, { useContext } from 'react';
import React from 'react';
import { ContextMenu, ContextMenuProps } from '../ContextMenu/ContextMenu';
import { ThemeContext } from '../../themes';
import { SeriesIcon } from '../Legend/SeriesIcon';
import { GraphDimensions } from './GraphTooltip/types';
import {
FlotDataPoint,
getValueFromDimension,
getDisplayProcessor,
formattedValueToString,
Dimensions,
dateTimeFormat,
TimeZone,
FormattedValue,
} 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';
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;
};
/** @internal */
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
getContextMenuSource,
timeZone,
......@@ -31,7 +34,6 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
contextDimensions,
...otherProps
}) => {
const theme = useContext(ThemeContext);
const source = getContextMenuSource();
// Do not render items that do not have label specified
......@@ -70,6 +72,33 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
});
return (
<GraphContextMenuHeader
timestamp={formattedValue}
seriesColor={source.series.color}
displayName={source.series.alias || source.series.label}
displayValue={value}
/>
);
};
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};
......@@ -77,31 +106,21 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
z-index: ${theme.zIndex.tooltip};
`}
>
<strong>{formattedValue}</strong>
<strong>{timestamp}</strong>
<HorizontalGroup>
<div>
<SeriesIcon color={source.series.color} />
<SeriesIcon color={seriesColor} />
<span
className={css`
white-space: nowrap;
padding-left: ${theme.spacing.xs};
`}
>
{source.series.alias || source.series.label}
</span>
{value && (
<span
className={css`
white-space: nowrap;
padding-left: ${theme.spacing.md};
`}
>
{formattedValueToString(value)}
{displayName}
</span>
)}
</div>
{displayValue && <FormattedValueDisplay value={displayValue} />}
</HorizontalGroup>
</div>
);
};
return <ContextMenu {...otherProps} items={itemsToRender} renderHeader={renderHeader} />;
};
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',
}}
/>
),
};
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 { GraphLegend } from './Graph/GraphLegend';
export { GraphWithLegend } from './Graph/GraphWithLegend';
export { GraphContextMenu } from './Graph/GraphContextMenu';
export { GraphContextMenu, GraphContextMenuHeader } from './Graph/GraphContextMenu';
export { BarGauge, BarGaugeDisplayMode } from './BarGauge/BarGauge';
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
export { VizRepeater, VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater';
......@@ -104,7 +104,8 @@ export { FullWidthButtonContainer } from './Button/FullWidthButtonContainer';
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
export * from './SingleStatShared/index';
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 { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
export { DataLinkInput } from './DataLinks/DataLinkInput';
......@@ -174,6 +175,7 @@ export { FileUpload } from './FileUpload/FileUpload';
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
export { Card, Props as CardProps, ContainerProps, CardInnerProps, getCardStyles } from './Card/Card';
export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDisplay';
// Legacy forms
// Export this until we've figured out a good approach to inline form styles.
......
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 { ZoomPlugin } from './ZoomPlugin';
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
export { ContextMenuPlugin } from './ContextMenuPlugin';
export { TooltipPlugin } from './TooltipPlugin';
import { ContextMenuItem } from '../components/ContextMenu/ContextMenu';
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
*/
export const linkModelToContextMenuItems: (links: () => LinkModel[]) => ContextMenuItem[] = links => {
export const linkModelToContextMenuItems: (links: () => LinkModel[]) => MenuItem[] = links => {
return links().map(link => {
return {
label: link.title,
// TODO: rename to href
url: link.href,
target: link.target,
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}`,
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
onClick: link.onClick,
};
});
......
import { ContextMenuItem } from '@grafana/ui';
import { MenuItem } from '@grafana/ui';
import { FlotDataPoint } from '@grafana/data';
export class GraphContextMenuCtrl {
private source?: FlotDataPoint | null;
private scope?: any;
menuItemsSupplier?: () => ContextMenuItem[];
menuItemsSupplier?: () => MenuItem[];
scrollContextElement: HTMLElement | null;
position: {
x: number;
......@@ -61,7 +61,7 @@ export class GraphContextMenuCtrl {
return this.source;
};
setMenuItemsSupplier = (menuItemsSupplier: () => ContextMenuItem[]) => {
setMenuItemsSupplier = (menuItemsSupplier: () => MenuItem[]) => {
this.menuItemsSupplier = menuItemsSupplier;
};
}
......@@ -24,7 +24,7 @@ import ReactDOM from 'react-dom';
import { GraphLegendProps, Legend } from './Legend/Legend';
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 {
DataFrame,
......@@ -197,10 +197,10 @@ class GraphElement {
getContextMenuItemsSupplier = (
flotPosition: { x: number; y: number },
linksSupplier?: LinkModelSupplier<FieldDisplay>
): (() => ContextMenuGroup[]) => {
): (() => MenuItemsGroup[]) => {
return () => {
// Fixed context menu items
const items: ContextMenuGroup[] = [
const items: MenuItemsGroup[] = [
{
items: [
{
......@@ -218,12 +218,12 @@ class GraphElement {
const dataLinks = [
{
items: linksSupplier.getLinks(this.panel.scopedVars).map<ContextMenuItem>(link => {
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'}`,
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
onClick: link.onClick,
};
}),
......
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 { Options } from './types';
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
interface GraphPanelProps extends PanelProps<Options> {}
......@@ -27,7 +28,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
>
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} />
<ContextMenuPlugin />
<ContextMenuPlugin timeZone={timeZone} />
{data.annotations ? <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} /> : <></>}
{data.annotations ? <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} /> : <></>}
</GraphNG>
......
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}
/>
);
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment