diff --git a/public/app/features/canvas/elements/metricValue.tsx b/public/app/features/canvas/elements/metricValue.tsx index 893d0bbd4086..8268470547a6 100644 --- a/public/app/features/canvas/elements/metricValue.tsx +++ b/public/app/features/canvas/elements/metricValue.tsx @@ -11,6 +11,7 @@ import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; +import { getDataLinks } from '../../../plugins/panel/canvas/utils'; import { CanvasElementItem, CanvasElementProps, defaultBgColor, defaultTextColor } from '../element'; import { ElementState } from '../runtime/element'; import { Align, TextConfig, TextData, VAlign } from '../types'; @@ -159,6 +160,8 @@ export const metricValueItem: CanvasElementItem = { data.color = ctx.getColor(cfg.color).value(); } + data.links = getDataLinks(ctx, cfg, data.text); + return data; }, diff --git a/public/app/features/canvas/elements/text.tsx b/public/app/features/canvas/elements/text.tsx index d8d0e02b66f8..b297778a037f 100644 --- a/public/app/features/canvas/elements/text.tsx +++ b/public/app/features/canvas/elements/text.tsx @@ -9,6 +9,7 @@ import { DimensionContext } from 'app/features/dimensions/context'; import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; +import { getDataLinks } from '../../../plugins/panel/canvas/utils'; import { CanvasElementItem, CanvasElementProps, defaultThemeTextColor } from '../element'; import { ElementState } from '../runtime/element'; import { Align, TextConfig, TextData, VAlign } from '../types'; @@ -158,6 +159,8 @@ export const textItem: CanvasElementItem = { data.color = ctx.getColor(cfg.color).value(); } + data.links = getDataLinks(ctx, cfg, data.text); + return data; }, diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 5fec464650ec..b954e96f18e2 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -446,6 +446,45 @@ export class ElementState implements LayerElement { } }; + handleMouseEnter = (event: React.MouseEvent, isSelected: boolean | undefined) => { + const scene = this.getScene(); + if (!scene?.isEditingEnabled) { + this.handleTooltip(event); + } else if (!isSelected) { + scene?.connections.handleMouseEnter(event); + } + }; + + handleTooltip = (event: React.MouseEvent) => { + const scene = this.getScene(); + if (scene?.tooltipCallback) { + const rect = this.div?.getBoundingClientRect(); + scene.tooltipCallback({ + anchorPoint: { x: rect?.right ?? event.pageX, y: rect?.top ?? event.pageY }, + element: this, + isOpen: false, + }); + } + }; + + handleMouseLeave = (event: React.MouseEvent) => { + const scene = this.getScene(); + if (scene?.tooltipCallback && !scene?.tooltip?.isOpen) { + scene.tooltipCallback(undefined); + } + }; + + onElementClick = (event: React.MouseEvent) => { + const scene = this.getScene(); + if (scene?.tooltipCallback && scene.tooltip?.anchorPoint) { + scene.tooltipCallback({ + anchorPoint: { x: scene.tooltip.anchorPoint.x, y: scene.tooltip.anchorPoint.y }, + element: this, + isOpen: true, + }); + } + }; + render() { const { item, div } = this; const scene = this.getScene(); @@ -456,7 +495,9 @@ export class ElementState implements LayerElement {
this.handleMouseEnter(e, isSelected)} + onMouseLeave={!scene?.isEditingEnabled ? this.handleMouseLeave : undefined} + onClick={!scene?.isEditingEnabled ? this.onElementClick : undefined} > void; setBackgroundCallback?: (anchorPoint: AnchorPoint) => void; + tooltipCallback?: (tooltip: CanvasTooltipPayload | undefined) => void; + tooltip?: CanvasTooltipPayload; + readonly editModeEnabled = new BehaviorSubject(false); subscription: Subscription; @@ -149,6 +153,7 @@ export class Scene { getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar), getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text), getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res), + getPanelData: () => this.data, }; updateData(data: PanelData) { @@ -600,6 +605,8 @@ export class Scene { render() { const canShowContextMenu = this.isPanelEditing || (!this.isPanelEditing && this.isEditingEnabled); + const canShowElementTooltip = + !this.isEditingEnabled && this.tooltip?.element && this.tooltip.element.data.links?.length > 0; return (
@@ -610,6 +617,11 @@ export class Scene { )} + {canShowElementTooltip && ( + + + + )}
); } diff --git a/public/app/features/canvas/types.ts b/public/app/features/canvas/types.ts index 5e2af18d89ea..23b1c8dd07ed 100644 --- a/public/app/features/canvas/types.ts +++ b/public/app/features/canvas/types.ts @@ -1,3 +1,4 @@ +import { LinkModel } from '@grafana/data/src'; import { ColorDimensionConfig, ResourceDimensionConfig, TextDimensionConfig } from 'app/features/dimensions/types'; export interface Placement { @@ -77,6 +78,7 @@ export interface TextData { size?: number; // 0 or missing will "auto size" align: Align; valign: VAlign; + links?: LinkModel[]; } export interface TextConfig { diff --git a/public/app/features/dimensions/context.ts b/public/app/features/dimensions/context.ts index d9415c2809d6..f4418e1a252d 100644 --- a/public/app/features/dimensions/context.ts +++ b/public/app/features/dimensions/context.ts @@ -1,3 +1,5 @@ +import { PanelData } from '@grafana/data/src'; + import { ColorDimensionConfig, DimensionSupplier, @@ -13,4 +15,5 @@ export interface DimensionContext { getScalar(scalar: ScalarDimensionConfig): DimensionSupplier; getText(text: TextDimensionConfig): DimensionSupplier; getResource(resource: ResourceDimensionConfig): DimensionSupplier; + getPanelData(): PanelData | undefined; } diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index eacad31a7dca..f2b5341578de 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -12,7 +12,7 @@ import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events'; import { InlineEdit } from './InlineEdit'; import { SetBackground } from './SetBackground'; import { PanelOptions } from './models.gen'; -import { AnchorPoint } from './types'; +import { AnchorPoint, CanvasTooltipPayload } from './types'; interface Props extends PanelProps {} @@ -71,6 +71,7 @@ export class CanvasPanel extends Component { this.scene.updateData(props.data); this.scene.inlineEditingCallback = this.openInlineEdit; this.scene.setBackgroundCallback = this.openSetBackground; + this.scene.tooltipCallback = this.tooltipCallback; this.subs.add( this.props.eventBus.subscribe(PanelEditEnteredEvent, (evt: PanelEditEnteredEvent) => { @@ -230,6 +231,11 @@ export class CanvasPanel extends Component { isSetBackgroundOpen = true; }; + tooltipCallback = (tooltip: CanvasTooltipPayload | undefined) => { + this.scene.tooltip = tooltip; + this.forceUpdate(); + }; + closeInlineEdit = () => { this.setState({ openInlineEdit: false }); isInlineEditOpen = false; diff --git a/public/app/plugins/panel/canvas/CanvasTooltip.tsx b/public/app/plugins/panel/canvas/CanvasTooltip.tsx new file mode 100644 index 000000000000..6fb46c975a42 --- /dev/null +++ b/public/app/plugins/panel/canvas/CanvasTooltip.tsx @@ -0,0 +1,80 @@ +import { css } from '@emotion/css'; +import { useDialog } from '@react-aria/dialog'; +import { useOverlay } from '@react-aria/overlays'; +import React, { createRef } from 'react'; + +import { GrafanaTheme2, LinkModel } from '@grafana/data/src'; +import { LinkButton, Portal, useStyles2, VerticalGroup, VizTooltipContainer } from '@grafana/ui'; +import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; +import { Scene } from 'app/features/canvas/runtime/scene'; + +interface Props { + scene: Scene; +} + +export const CanvasTooltip = ({ scene }: Props) => { + const style = useStyles2(getStyles); + + const onClose = () => { + if (scene?.tooltipCallback && scene.tooltip) { + scene.tooltipCallback(undefined); + } + }; + + const ref = createRef(); + const { overlayProps } = useOverlay({ onClose: onClose, isDismissable: true }, ref); + const { dialogProps } = useDialog({}, ref); + + const element = scene.tooltip?.element; + if (!element) { + return <>; + } + + const renderDataLinks = () => + element.data?.links && + element.data?.links.length > 0 && ( +
+ + {element.data?.links?.map((link: LinkModel, i: number) => ( + + {link.title} + + ))} + +
+ ); + + return ( + <> + {scene.tooltip?.element && scene.tooltip.anchorPoint && ( + + +
+ {scene.tooltip.isOpen && } +
{renderDataLinks()}
+
+
+
+ )} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css` + margin-top: 20px; + background: ${theme.colors.background.primary}; + `, +}); diff --git a/public/app/plugins/panel/canvas/types.ts b/public/app/plugins/panel/canvas/types.ts index 874971e565d4..e5b2ee138d6b 100644 --- a/public/app/plugins/panel/canvas/types.ts +++ b/public/app/plugins/panel/canvas/types.ts @@ -25,3 +25,9 @@ export type AnchorPoint = { x: number; y: number; }; + +export interface CanvasTooltipPayload { + anchorPoint: AnchorPoint | undefined; + element: ElementState | undefined; + isOpen?: boolean; +} diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index a5eae1208a9e..0b5e4b9c2c90 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -1,4 +1,4 @@ -import { AppEvents, PluginState, SelectableValue } from '@grafana/data'; +import { AppEvents, Field, LinkModel, PluginState, SelectableValue } from '@grafana/data'; import { hasAlphaPanels } from 'app/core/config'; import appEvents from '../../../core/app_events'; @@ -8,11 +8,13 @@ import { CanvasElementOptions, canvasElementRegistry, defaultElementItems, + TextConfig, } from '../../../features/canvas'; import { notFoundItem } from '../../../features/canvas/elements/notFound'; import { ElementState } from '../../../features/canvas/runtime/element'; import { FrameState } from '../../../features/canvas/runtime/frame'; import { Scene, SelectionParams } from '../../../features/canvas/runtime/scene'; +import { DimensionContext } from '../../../features/dimensions'; import { AnchorPoint } from './types'; @@ -99,3 +101,31 @@ export function onAddItem(sel: SelectableValue, rootLayer: FrameState | setTimeout(() => doSelect(rootLayer.scene, newElement)); } } + +export function getDataLinks(ctx: DimensionContext, cfg: TextConfig, textData: string | undefined): LinkModel[] { + const panelData = ctx.getPanelData(); + const frames = panelData?.series; + + const links: Array> = []; + const linkLookup = new Set(); + + frames?.forEach((frame) => { + const visibleFields = frame.fields.filter((field) => !Boolean(field.config.custom?.hideFrom?.tooltip)); + + if (cfg.text?.field && visibleFields.some((f) => f.name === cfg.text?.field)) { + const field = visibleFields.filter((field) => field.name === cfg.text?.field)[0]; + if (field?.getLinks) { + const disp = field.display ? field.display(textData) : { text: `${textData}`, numeric: +textData! }; + field.getLinks({ calculatedValue: disp }).forEach((link) => { + const key = `${link.title}/${link.href}`; + if (!linkLookup.has(key)) { + links.push(link); + linkLookup.add(key); + } + }); + } + } + }); + + return links; +} diff --git a/public/app/plugins/panel/icon/IconPanel.tsx b/public/app/plugins/panel/icon/IconPanel.tsx index 2c679db70842..70e8028362ba 100644 --- a/public/app/plugins/panel/icon/IconPanel.tsx +++ b/public/app/plugins/panel/icon/IconPanel.tsx @@ -57,6 +57,7 @@ export class IconPanel extends Component { getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.props.data, scalar), getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.props.data, text), getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.props.data, res), + getPanelData: () => this.props.data, }; shouldComponentUpdate(nextProps: Props) {