Canvas: Add tooltip for data links (#61648)

This commit is contained in:
Adela Almasan 2023-01-20 10:26:51 -06:00 committed by GitHub
parent c1251311d0
commit cef15873d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 191 additions and 4 deletions

View File

@ -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<TextConfig, TextData> = {
data.color = ctx.getColor(cfg.color).value();
}
data.links = getDataLinks(ctx, cfg, data.text);
return data;
},

View File

@ -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<TextConfig, TextData> = {
data.color = ctx.getColor(cfg.color).value();
}
data.links = getDataLinks(ctx, cfg, data.text);
return data;
},

View File

@ -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 {
<div
key={this.UID}
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
key={`${this.UID}/${this.revId}`}

View File

@ -26,9 +26,10 @@ import {
getTextDimensionFromData,
} from 'app/features/dimensions/utils';
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 { 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 { CanvasPanel } from '../../../plugins/panel/canvas/CanvasPanel';
@ -74,6 +75,9 @@ export class Scene {
inlineEditingCallback?: () => void;
setBackgroundCallback?: (anchorPoint: AnchorPoint) => void;
tooltipCallback?: (tooltip: CanvasTooltipPayload | undefined) => void;
tooltip?: CanvasTooltipPayload;
readonly editModeEnabled = new BehaviorSubject<boolean>(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 (
<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} />
</Portal>
)}
{canShowElementTooltip && (
<Portal>
<CanvasTooltip scene={this} />
</Portal>
)}
</div>
);
}

View File

@ -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 {

View File

@ -1,3 +1,5 @@
import { PanelData } from '@grafana/data/src';
import {
ColorDimensionConfig,
DimensionSupplier,
@ -13,4 +15,5 @@ export interface DimensionContext {
getScalar(scalar: ScalarDimensionConfig): DimensionSupplier<number>;
getText(text: TextDimensionConfig): DimensionSupplier<string>;
getResource(resource: ResourceDimensionConfig): DimensionSupplier<string>;
getPanelData(): PanelData | undefined;
}

View File

@ -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<PanelOptions> {}
@ -71,6 +71,7 @@ export class CanvasPanel extends Component<Props, State> {
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<Props, State> {
isSetBackgroundOpen = true;
};
tooltipCallback = (tooltip: CanvasTooltipPayload | undefined) => {
this.scene.tooltip = tooltip;
this.forceUpdate();
};
closeInlineEdit = () => {
this.setState({ openInlineEdit: false });
isInlineEditOpen = false;

View 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};
`,
});

View File

@ -25,3 +25,9 @@ export type AnchorPoint = {
x: number;
y: number;
};
export interface CanvasTooltipPayload {
anchorPoint: AnchorPoint | undefined;
element: ElementState | undefined;
isOpen?: boolean;
}

View File

@ -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<string>, 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<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;
}

View File

@ -57,6 +57,7 @@ export class IconPanel extends Component<Props> {
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) {