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 { 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';
|
||||
|
@ -1,11 +1,12 @@
|
||||
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>
|
||||
);
|
||||
|
@ -1,17 +1,19 @@
|
||||
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
|
||||
@ -69,6 +71,33 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
timeZone,
|
||||
});
|
||||
|
||||
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`
|
||||
@ -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}
|
||||
{displayName}
|
||||
</span>
|
||||
{value && (
|
||||
<span
|
||||
className={css`
|
||||
white-space: nowrap;
|
||||
padding-left: ${theme.spacing.md};
|
||||
`}
|
||||
>
|
||||
{formattedValueToString(value)}
|
||||
</span>
|
||||
)}
|
||||
</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 { 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.
|
||||
|
@ -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 { ZoomPlugin } from './ZoomPlugin';
|
||||
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
|
||||
export { ContextMenuPlugin } from './ContextMenuPlugin';
|
||||
export { TooltipPlugin } from './TooltipPlugin';
|
||||
|
@ -1,17 +1,18 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
@ -1,10 +1,10 @@
|
||||
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,
|
||||
};
|
||||
}),
|
||||
|
@ -1,9 +1,10 @@
|
||||
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>
|
||||
|
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