diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx index 7a584f08f1e..9cb7adac2ed 100644 --- a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx @@ -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) => 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 = React.memo(({ x, y, onClose, items, renderHeader }) => { - const theme = useTheme(); const menuRef = useRef(null); const [positionStyles, setPositionStyles] = useState({}); @@ -143,96 +44,12 @@ export const ContextMenu: React.FC = React.memo(({ x, y, onClo } }); - const styles = getContextMenuStyles(theme); const header = renderHeader && renderHeader(); return ( -
- {header &&
{header}
} - { - return ; - }} - /> -
+ ); }); -interface ContextMenuItemProps { - label: string; - icon?: string; - url?: string; - target?: string; - onClick?: (e: React.MouseEvent) => void; - className?: string; -} - -const ContextMenuItemComponent: React.FC = React.memo( - ({ url, icon, label, target, onClick, className }) => { - const theme = useTheme(); - const styles = getContextMenuStyles(theme); - return ( - - ); - } -); -ContextMenuItemComponent.displayName = 'ContextMenuItemComponent'; - -interface ContextMenuGroupProps { - group: ContextMenuGroup; - onClick?: () => void; // Used with 'onClose' -} - -const ContextMenuGroupComponent: React.FC = ({ group, onClick }) => { - const theme = useTheme(); - const styles = getContextMenuStyles(theme); - - if (group.items.length === 0) { - return null; - } - - return ( -
- {group.label &&
{group.label}
} - { - return ( - ) => { - if (item.onClick) { - item.onClick(e); - } - - // Typically closes the context menu - if (onClick) { - onClick(); - } - }} - /> - ); - }} - /> -
- ); -}; ContextMenu.displayName = 'ContextMenu'; diff --git a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx index 5410ca95cbf..df7fa9ff323 100644 --- a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx @@ -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 }) => JSX.Element; /** A function that returns an array of menu items */ - getContextMenuItems: () => ContextMenuGroup[]; + getContextMenuItems: () => MenuItemsGroup[]; } export const WithContextMenu: React.FC = ({ children, getContextMenuItems }) => { diff --git a/packages/grafana-ui/src/components/FormattedValueDisplay/FormattedValueDisplay.tsx b/packages/grafana-ui/src/components/FormattedValueDisplay/FormattedValueDisplay.tsx index a5a4b04b87b..decb8f2486b 100644 --- a/packages/grafana-ui/src/components/FormattedValueDisplay/FormattedValueDisplay.tsx +++ b/packages/grafana-ui/src/components/FormattedValueDisplay/FormattedValueDisplay.tsx @@ -2,9 +2,9 @@ import React, { FC, CSSProperties, HTMLProps } from 'react'; import { FormattedValue } from '@grafana/data'; export interface Props extends Omit, '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 = ({ 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 (
{hasPrefix && {value.prefix}} {value.text} - {hasSuffix && {value.suffix}} + {hasSuffix && {value.suffix}}
); diff --git a/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx b/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx index 0fd029d0fc0..7159ae51481 100644 --- a/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx +++ b/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx @@ -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 = { [key in keyof T]: [number, number | undefined] | null }; @@ -23,6 +25,7 @@ export type GraphContextMenuProps = ContextMenuProps & { contextDimensions?: ContextDimensions; }; +/** @internal */ export const GraphContextMenu: React.FC = ({ getContextMenuSource, timeZone, @@ -31,7 +34,6 @@ export const GraphContextMenu: React.FC = ({ contextDimensions, ...otherProps }) => { - const theme = useContext(ThemeContext); const source = getContextMenuSource(); // Do not render items that do not have label specified @@ -70,38 +72,55 @@ export const GraphContextMenu: React.FC = ({ }); return ( -
- {formattedValue} + + ); + }; + + return ; +}; + +/** @internal */ +export const GraphContextMenuHeader = ({ + timestamp, + seriesColor, + displayName, + displayValue, +}: { + timestamp: string; + seriesColor: string; + displayName: string; + displayValue: FormattedValue; +}) => { + const theme = useTheme(); + + return ( +
+ {timestamp} +
- + - {source.series.alias || source.series.label} + {displayName} - {value && ( - - {formattedValueToString(value)} - - )}
-
- ); - }; - - return ; + {displayValue && } + +
+ ); }; diff --git a/packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx b/packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx new file mode 100644 index 00000000000..5dc026937ef --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx @@ -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 = args => ( +
+ +
+); + +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: ( + + ), +}; diff --git a/packages/grafana-ui/src/components/Menu/Menu.tsx b/packages/grafana-ui/src/components/Menu/Menu.tsx new file mode 100644 index 00000000000..ef680ebb956 --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/Menu.tsx @@ -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) => 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 { + /** 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(({ header, items, onClose, ...otherProps }, ref) => { + const styles = useStyles(getMenuStyles); + const onClick = useCallback(() => { + if (onClose) { + onClose(); + } + }, [onClose]); + + return ( +
+ {header &&
{header}
} + { + return ; + }} + /> +
+ ); +}); +Menu.displayName = 'Menu'; + +interface MenuGroupProps { + group: MenuItemsGroup; + onClick?: () => void; // Used with 'onClose' +} + +const MenuGroup: React.FC = ({ group, onClick }) => { + const styles = useStyles(getMenuStyles); + + if (group.items.length === 0) { + return null; + } + + return ( +
+ {group.label &&
{group.label}
} + { + return ( + ) => { + if (item.onClick) { + item.onClick(e); + } + + // Typically closes the context menu + if (onClick) { + onClick(); + } + }} + /> + ); + }} + /> +
+ ); +}; +MenuGroup.displayName = 'MenuGroup'; + +interface MenuItemProps { + label: string; + icon?: IconName; + url?: string; + target?: string; + onClick?: (e: React.MouseEvent) => void; + className?: string; +} + +const MenuItemComponent: React.FC = React.memo(({ url, icon, label, target, onClick, className }) => { + const styles = useStyles(getMenuStyles); + return ( + + ); +}); +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}; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index b13b25008a0..99270e0b8d8 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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. diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ContextMenuPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ContextMenuPlugin.tsx deleted file mode 100644 index 2334b1b1f03..00000000000 --- a/packages/grafana-ui/src/components/uPlot/plugins/ContextMenuPlugin.tsx +++ /dev/null @@ -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 = ({ onClose }) => { - const [isOpen, setIsOpen] = useState(false); - - const onClick = useCallback(() => { - setIsOpen(!isOpen); - }, [setIsOpen]); - - return ( - - {({ point, coords, clearSelection }) => { - return ( - - { - clearSelection(); - if (onClose) { - onClose(); - } - }} - /> - - ); - }} - - ); -}; - -interface ContextMenuProps { - onClose?: () => void; - selection: any; -} - -const ContextMenu: React.FC = ({ onClose, selection }) => { - const ref = useRef(null); - - useClickAway(ref, () => { - if (onClose) { - onClose(); - } - }); - - return ( -
- Point: {JSON.stringify(selection.point)}
- Viewport coords: {JSON.stringify(selection.coords.viewport)} -
- ); -}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/index.ts b/packages/grafana-ui/src/components/uPlot/plugins/index.ts index 3858cb9eaeb..86317d27502 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/index.ts +++ b/packages/grafana-ui/src/components/uPlot/plugins/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/utils/dataLinks.ts b/packages/grafana-ui/src/utils/dataLinks.ts index 36ebd9e0f77..497c9a308df 100644 --- a/packages/grafana-ui/src/utils/dataLinks.ts +++ b/packages/grafana-ui/src/utils/dataLinks.ts @@ -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, }; }); diff --git a/public/app/plugins/panel/graph/GraphContextMenuCtrl.ts b/public/app/plugins/panel/graph/GraphContextMenuCtrl.ts index 519487f707c..f7cf0380212 100644 --- a/public/app/plugins/panel/graph/GraphContextMenuCtrl.ts +++ b/public/app/plugins/panel/graph/GraphContextMenuCtrl.ts @@ -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; }; } diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 1c5d936e7d4..3b858f0208f 100644 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -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 - ): (() => 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(link => { + items: linksSupplier.getLinks(this.panel.scopedVars).map(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, }; }), diff --git a/public/app/plugins/panel/graph3/GraphPanel.tsx b/public/app/plugins/panel/graph3/GraphPanel.tsx index 86395c7340d..45a1fc034a6 100644 --- a/public/app/plugins/panel/graph3/GraphPanel.tsx +++ b/public/app/plugins/panel/graph3/GraphPanel.tsx @@ -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 {} @@ -27,7 +28,7 @@ export const GraphPanel: React.FC = ({ > - + {data.annotations ? : <>} {data.annotations ? : <>} diff --git a/public/app/plugins/panel/graph3/plugins/ContextMenuPlugin.tsx b/public/app/plugins/panel/graph3/plugins/ContextMenuPlugin.tsx new file mode 100644 index 00000000000..db87accb81b --- /dev/null +++ b/public/app/plugins/panel/graph3/plugins/ContextMenuPlugin.tsx @@ -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 = ({ onClose, timeZone, defaultItems }) => { + const [isOpen, setIsOpen] = useState(false); + + const onClick = useCallback(() => { + setIsOpen(!isOpen); + }, [setIsOpen]); + + return ( + + {({ point, coords, clearSelection }) => { + return ( + + { + clearSelection(); + if (onClose) { + onClose(); + } + }} + /> + + ); + }} + + ); +}; + +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 = ({ 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(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 = () => ( + + ); + } + + return { + renderHeader, + items, + }; + }, [defaultItems, seriesIdx, dataIdx, data]); + + return ( + + ); +};