mirror of
https://github.com/grafana/grafana.git
synced 2025-09-20 15:13:52 +08:00
Canvas: Add tooltip for data links (#61648)
This commit is contained in:
@ -11,6 +11,7 @@ import { DimensionContext } from 'app/features/dimensions/context';
|
|||||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||||
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
|
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
|
||||||
|
|
||||||
|
import { getDataLinks } from '../../../plugins/panel/canvas/utils';
|
||||||
import { CanvasElementItem, CanvasElementProps, defaultBgColor, defaultTextColor } from '../element';
|
import { CanvasElementItem, CanvasElementProps, defaultBgColor, defaultTextColor } from '../element';
|
||||||
import { ElementState } from '../runtime/element';
|
import { ElementState } from '../runtime/element';
|
||||||
import { Align, TextConfig, TextData, VAlign } from '../types';
|
import { Align, TextConfig, TextData, VAlign } from '../types';
|
||||||
@ -159,6 +160,8 @@ export const metricValueItem: CanvasElementItem<TextConfig, TextData> = {
|
|||||||
data.color = ctx.getColor(cfg.color).value();
|
data.color = ctx.getColor(cfg.color).value();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.links = getDataLinks(ctx, cfg, data.text);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ import { DimensionContext } from 'app/features/dimensions/context';
|
|||||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||||
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
|
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
|
||||||
|
|
||||||
|
import { getDataLinks } from '../../../plugins/panel/canvas/utils';
|
||||||
import { CanvasElementItem, CanvasElementProps, defaultThemeTextColor } from '../element';
|
import { CanvasElementItem, CanvasElementProps, defaultThemeTextColor } from '../element';
|
||||||
import { ElementState } from '../runtime/element';
|
import { ElementState } from '../runtime/element';
|
||||||
import { Align, TextConfig, TextData, VAlign } from '../types';
|
import { Align, TextConfig, TextData, VAlign } from '../types';
|
||||||
@ -158,6 +159,8 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = {
|
|||||||
data.color = ctx.getColor(cfg.color).value();
|
data.color = ctx.getColor(cfg.color).value();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.links = getDataLinks(ctx, cfg, data.text);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -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() {
|
render() {
|
||||||
const { item, div } = this;
|
const { item, div } = this;
|
||||||
const scene = this.getScene();
|
const scene = this.getScene();
|
||||||
@ -456,7 +495,9 @@ export class ElementState implements LayerElement {
|
|||||||
<div
|
<div
|
||||||
key={this.UID}
|
key={this.UID}
|
||||||
ref={this.initElement}
|
ref={this.initElement}
|
||||||
onMouseEnter={!isSelected ? scene?.connections.handleMouseEnter : undefined}
|
onMouseEnter={(e: React.MouseEvent) => this.handleMouseEnter(e, isSelected)}
|
||||||
|
onMouseLeave={!scene?.isEditingEnabled ? this.handleMouseLeave : undefined}
|
||||||
|
onClick={!scene?.isEditingEnabled ? this.onElementClick : undefined}
|
||||||
>
|
>
|
||||||
<item.display
|
<item.display
|
||||||
key={`${this.UID}/${this.revId}`}
|
key={`${this.UID}/${this.revId}`}
|
||||||
|
@ -26,9 +26,10 @@ import {
|
|||||||
getTextDimensionFromData,
|
getTextDimensionFromData,
|
||||||
} from 'app/features/dimensions/utils';
|
} from 'app/features/dimensions/utils';
|
||||||
import { CanvasContextMenu } from 'app/plugins/panel/canvas/CanvasContextMenu';
|
import { CanvasContextMenu } from 'app/plugins/panel/canvas/CanvasContextMenu';
|
||||||
|
import { CanvasTooltip } from 'app/plugins/panel/canvas/CanvasTooltip';
|
||||||
import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/ConnectionAnchors';
|
import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/ConnectionAnchors';
|
||||||
import { Connections } from 'app/plugins/panel/canvas/Connections';
|
import { Connections } from 'app/plugins/panel/canvas/Connections';
|
||||||
import { AnchorPoint, LayerActionID } from 'app/plugins/panel/canvas/types';
|
import { AnchorPoint, CanvasTooltipPayload, LayerActionID } from 'app/plugins/panel/canvas/types';
|
||||||
|
|
||||||
import appEvents from '../../../core/app_events';
|
import appEvents from '../../../core/app_events';
|
||||||
import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel';
|
import { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel';
|
||||||
@ -74,6 +75,9 @@ export class Scene {
|
|||||||
inlineEditingCallback?: () => void;
|
inlineEditingCallback?: () => void;
|
||||||
setBackgroundCallback?: (anchorPoint: AnchorPoint) => void;
|
setBackgroundCallback?: (anchorPoint: AnchorPoint) => void;
|
||||||
|
|
||||||
|
tooltipCallback?: (tooltip: CanvasTooltipPayload | undefined) => void;
|
||||||
|
tooltip?: CanvasTooltipPayload;
|
||||||
|
|
||||||
readonly editModeEnabled = new BehaviorSubject<boolean>(false);
|
readonly editModeEnabled = new BehaviorSubject<boolean>(false);
|
||||||
subscription: Subscription;
|
subscription: Subscription;
|
||||||
|
|
||||||
@ -149,6 +153,7 @@ export class Scene {
|
|||||||
getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar),
|
getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar),
|
||||||
getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text),
|
getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text),
|
||||||
getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res),
|
getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res),
|
||||||
|
getPanelData: () => this.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateData(data: PanelData) {
|
updateData(data: PanelData) {
|
||||||
@ -600,6 +605,8 @@ export class Scene {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const canShowContextMenu = this.isPanelEditing || (!this.isPanelEditing && this.isEditingEnabled);
|
const canShowContextMenu = this.isPanelEditing || (!this.isPanelEditing && this.isEditingEnabled);
|
||||||
|
const canShowElementTooltip =
|
||||||
|
!this.isEditingEnabled && this.tooltip?.element && this.tooltip.element.data.links?.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
|
<div key={this.revId} className={this.styles.wrap} style={this.style} ref={this.setRef}>
|
||||||
@ -610,6 +617,11 @@ export class Scene {
|
|||||||
<CanvasContextMenu scene={this} panel={this.panel} />
|
<CanvasContextMenu scene={this} panel={this.panel} />
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
{canShowElementTooltip && (
|
||||||
|
<Portal>
|
||||||
|
<CanvasTooltip scene={this} />
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { LinkModel } from '@grafana/data/src';
|
||||||
import { ColorDimensionConfig, ResourceDimensionConfig, TextDimensionConfig } from 'app/features/dimensions/types';
|
import { ColorDimensionConfig, ResourceDimensionConfig, TextDimensionConfig } from 'app/features/dimensions/types';
|
||||||
|
|
||||||
export interface Placement {
|
export interface Placement {
|
||||||
@ -77,6 +78,7 @@ export interface TextData {
|
|||||||
size?: number; // 0 or missing will "auto size"
|
size?: number; // 0 or missing will "auto size"
|
||||||
align: Align;
|
align: Align;
|
||||||
valign: VAlign;
|
valign: VAlign;
|
||||||
|
links?: LinkModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextConfig {
|
export interface TextConfig {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { PanelData } from '@grafana/data/src';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ColorDimensionConfig,
|
ColorDimensionConfig,
|
||||||
DimensionSupplier,
|
DimensionSupplier,
|
||||||
@ -13,4 +15,5 @@ export interface DimensionContext {
|
|||||||
getScalar(scalar: ScalarDimensionConfig): DimensionSupplier<number>;
|
getScalar(scalar: ScalarDimensionConfig): DimensionSupplier<number>;
|
||||||
getText(text: TextDimensionConfig): DimensionSupplier<string>;
|
getText(text: TextDimensionConfig): DimensionSupplier<string>;
|
||||||
getResource(resource: ResourceDimensionConfig): DimensionSupplier<string>;
|
getResource(resource: ResourceDimensionConfig): DimensionSupplier<string>;
|
||||||
|
getPanelData(): PanelData | undefined;
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events';
|
|||||||
import { InlineEdit } from './InlineEdit';
|
import { InlineEdit } from './InlineEdit';
|
||||||
import { SetBackground } from './SetBackground';
|
import { SetBackground } from './SetBackground';
|
||||||
import { PanelOptions } from './models.gen';
|
import { PanelOptions } from './models.gen';
|
||||||
import { AnchorPoint } from './types';
|
import { AnchorPoint, CanvasTooltipPayload } from './types';
|
||||||
|
|
||||||
interface Props extends PanelProps<PanelOptions> {}
|
interface Props extends PanelProps<PanelOptions> {}
|
||||||
|
|
||||||
@ -71,6 +71,7 @@ export class CanvasPanel extends Component<Props, State> {
|
|||||||
this.scene.updateData(props.data);
|
this.scene.updateData(props.data);
|
||||||
this.scene.inlineEditingCallback = this.openInlineEdit;
|
this.scene.inlineEditingCallback = this.openInlineEdit;
|
||||||
this.scene.setBackgroundCallback = this.openSetBackground;
|
this.scene.setBackgroundCallback = this.openSetBackground;
|
||||||
|
this.scene.tooltipCallback = this.tooltipCallback;
|
||||||
|
|
||||||
this.subs.add(
|
this.subs.add(
|
||||||
this.props.eventBus.subscribe(PanelEditEnteredEvent, (evt: PanelEditEnteredEvent) => {
|
this.props.eventBus.subscribe(PanelEditEnteredEvent, (evt: PanelEditEnteredEvent) => {
|
||||||
@ -230,6 +231,11 @@ export class CanvasPanel extends Component<Props, State> {
|
|||||||
isSetBackgroundOpen = true;
|
isSetBackgroundOpen = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tooltipCallback = (tooltip: CanvasTooltipPayload | undefined) => {
|
||||||
|
this.scene.tooltip = tooltip;
|
||||||
|
this.forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
closeInlineEdit = () => {
|
closeInlineEdit = () => {
|
||||||
this.setState({ openInlineEdit: false });
|
this.setState({ openInlineEdit: false });
|
||||||
isInlineEditOpen = false;
|
isInlineEditOpen = false;
|
||||||
|
80
public/app/plugins/panel/canvas/CanvasTooltip.tsx
Normal file
80
public/app/plugins/panel/canvas/CanvasTooltip.tsx
Normal file
@ -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<HTMLElement>();
|
||||||
|
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 && (
|
||||||
|
<div>
|
||||||
|
<VerticalGroup>
|
||||||
|
{element.data?.links?.map((link: LinkModel, i: number) => (
|
||||||
|
<LinkButton
|
||||||
|
key={i}
|
||||||
|
icon={'external-link-alt'}
|
||||||
|
target={link.target}
|
||||||
|
href={link.href}
|
||||||
|
onClick={link.onClick}
|
||||||
|
fill="text"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</LinkButton>
|
||||||
|
))}
|
||||||
|
</VerticalGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{scene.tooltip?.element && scene.tooltip.anchorPoint && (
|
||||||
|
<Portal>
|
||||||
|
<VizTooltipContainer
|
||||||
|
position={{ x: scene.tooltip.anchorPoint.x, y: scene.tooltip.anchorPoint.y }}
|
||||||
|
offset={{ x: 5, y: 0 }}
|
||||||
|
allowPointerEvents={scene.tooltip.isOpen}
|
||||||
|
>
|
||||||
|
<section ref={ref} {...overlayProps} {...dialogProps}>
|
||||||
|
{scene.tooltip.isOpen && <CloseButton style={{ zIndex: 1 }} onClick={onClose} />}
|
||||||
|
<div className={style.wrapper}>{renderDataLinks()}</div>
|
||||||
|
</section>
|
||||||
|
</VizTooltipContainer>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
wrapper: css`
|
||||||
|
margin-top: 20px;
|
||||||
|
background: ${theme.colors.background.primary};
|
||||||
|
`,
|
||||||
|
});
|
@ -25,3 +25,9 @@ export type AnchorPoint = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface CanvasTooltipPayload {
|
||||||
|
anchorPoint: AnchorPoint | undefined;
|
||||||
|
element: ElementState | undefined;
|
||||||
|
isOpen?: boolean;
|
||||||
|
}
|
||||||
|
@ -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 { hasAlphaPanels } from 'app/core/config';
|
||||||
|
|
||||||
import appEvents from '../../../core/app_events';
|
import appEvents from '../../../core/app_events';
|
||||||
@ -8,11 +8,13 @@ import {
|
|||||||
CanvasElementOptions,
|
CanvasElementOptions,
|
||||||
canvasElementRegistry,
|
canvasElementRegistry,
|
||||||
defaultElementItems,
|
defaultElementItems,
|
||||||
|
TextConfig,
|
||||||
} from '../../../features/canvas';
|
} from '../../../features/canvas';
|
||||||
import { notFoundItem } from '../../../features/canvas/elements/notFound';
|
import { notFoundItem } from '../../../features/canvas/elements/notFound';
|
||||||
import { ElementState } from '../../../features/canvas/runtime/element';
|
import { ElementState } from '../../../features/canvas/runtime/element';
|
||||||
import { FrameState } from '../../../features/canvas/runtime/frame';
|
import { FrameState } from '../../../features/canvas/runtime/frame';
|
||||||
import { Scene, SelectionParams } from '../../../features/canvas/runtime/scene';
|
import { Scene, SelectionParams } from '../../../features/canvas/runtime/scene';
|
||||||
|
import { DimensionContext } from '../../../features/dimensions';
|
||||||
|
|
||||||
import { AnchorPoint } from './types';
|
import { AnchorPoint } from './types';
|
||||||
|
|
||||||
@ -99,3 +101,31 @@ export function onAddItem(sel: SelectableValue<string>, rootLayer: FrameState |
|
|||||||
setTimeout(() => doSelect(rootLayer.scene, newElement));
|
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<LinkModel<Field>> = [];
|
||||||
|
const linkLookup = new Set<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
@ -57,6 +57,7 @@ export class IconPanel extends Component<Props> {
|
|||||||
getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.props.data, scalar),
|
getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.props.data, scalar),
|
||||||
getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.props.data, text),
|
getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.props.data, text),
|
||||||
getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.props.data, res),
|
getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.props.data, res),
|
||||||
|
getPanelData: () => this.props.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps: Props) {
|
shouldComponentUpdate(nextProps: Props) {
|
||||||
|
Reference in New Issue
Block a user