Canvas: Element data links refactor (#90636)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Adela Almasan 2024-07-25 06:13:21 -06:00 committed by GitHub
parent d3061ab61a
commit 0a870e6a88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 113 additions and 83 deletions

View File

@ -190,12 +190,12 @@ You can configure a canvas data link to open with a single click on the element.
1. Click the element to which you want to add the data link.
1. In either the inline editor or panel editor, expand the **Selected element** editor.
1. Scroll down to the **Data links** section and expand it.
1. Toggle the **One-click** switch in the element's data links section.
1. In the **One-click** section, choose **Link**.
1. Disable inline editing.
The first data link in the list will be configured as your one-click data link. If you want to change the one-click data link, simply drag the desired data link to the top of the list.
{{< video-embed src="/media/docs/grafana/panels-visualizations/canvas-one-click-data-link.mp4" >}}
{{< video-embed src="/media/docs/grafana/panels-visualizations/canvas-one-click-datalink-.mp4" >}}
## Panel options

View File

@ -479,7 +479,6 @@ export const getLinksSupplier =
href,
title: replaceVariables(link.title || '', dataLinkScopedVars),
target: link.targetBlank ? '_blank' : undefined,
sortIndex: link.sortIndex,
onClick: (evt: MouseEvent, origin: Field) => {
link.onClick!({
origin: origin ?? field,
@ -495,7 +494,6 @@ export const getLinksSupplier =
title: replaceVariables(link.title || '', dataLinkScopedVars),
target: link.targetBlank ? '_blank' : undefined,
origin: field,
sortIndex: link.sortIndex,
};
}

View File

@ -793,6 +793,7 @@ export {
VariableOrigin,
type VariableSuggestion,
VariableSuggestionsScope,
OneClickMode,
} from './types/dataLink';
export { DataFrameType } from './types/dataFrameTypes';
export {

View File

@ -99,7 +99,6 @@ export interface LinkModel<T = any> {
// When a click callback exists, this is passed the raw mouse|react event
onClick?: (e: any, origin?: any) => void;
sortIndex?: number;
}
/**
@ -130,3 +129,8 @@ export interface VariableSuggestion {
export enum VariableSuggestionsScope {
Values = 'values',
}
export enum OneClickMode {
Link = 'link',
Off = 'off',
}

View File

@ -104,7 +104,6 @@ export interface CanvasElementOptions {
connections?: Array<CanvasConnection>;
constraint?: Constraint;
name: string;
oneClickLinks?: boolean;
placement?: Placement;
type: string;
}

View File

@ -17,7 +17,7 @@ interface DataLinksInlineEditorProps {
onChange: (links: DataLink[]) => void;
getSuggestions: () => VariableSuggestion[];
data: DataFrame[];
oneClickEnabled?: boolean;
showOneClick?: boolean;
}
export const DataLinksInlineEditor = ({
@ -25,13 +25,12 @@ export const DataLinksInlineEditor = ({
onChange,
getSuggestions,
data,
oneClickEnabled = false,
showOneClick = false,
}: DataLinksInlineEditorProps) => {
const [editIndex, setEditIndex] = useState<number | null>(null);
const [isNew, setIsNew] = useState(false);
const [linksSafe, setLinksSafe] = useState<DataLink[]>([]);
links?.sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0));
useEffect(() => {
setLinksSafe(links ?? []);
@ -81,24 +80,20 @@ export const DataLinksInlineEditor = ({
return;
}
const copy = [...linksSafe];
const link = copy[result.source.index];
link.sortIndex = result.destination.index;
const update = cloneDeep(linksSafe);
const link = update[result.source.index];
const swapLink = copy[result.destination.index];
swapLink.sortIndex = result.source.index;
update.splice(result.source.index, 1);
update.splice(result.destination.index, 0, link);
copy.splice(result.source.index, 1);
copy.splice(result.destination.index, 0, link);
setLinksSafe(copy);
onChange(linksSafe);
setLinksSafe(update);
onChange(update);
};
const renderFirstLink = (linkJSX: ReactNode) => {
if (oneClickEnabled) {
const renderFirstLink = (linkJSX: ReactNode, key: string) => {
if (showOneClick) {
return (
<div className={styles.oneClickOverlay}>
<div className={styles.oneClickOverlay} key={key}>
<span className={styles.oneClickSpan}>One-click</span>
{linkJSX}
</div>
@ -130,7 +125,7 @@ export const DataLinksInlineEditor = ({
);
if (idx === 0) {
return renderFirstLink(linkJSX);
return renderFirstLink(linkJSX, key);
}
return linkJSX;

View File

@ -1,6 +1,6 @@
import { ComponentType } from 'react';
import { DataLink, RegistryItem } from '@grafana/data';
import { DataLink, RegistryItem, OneClickMode } from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
import { ColorDimensionConfig, ScaleDimensionConfig } from '@grafana/schema';
import { config } from 'app/core/config';
@ -33,7 +33,7 @@ export interface CanvasElementOptions<TConfig = any> {
border?: LineConfig;
connections?: CanvasConnection[];
links?: DataLink[];
oneClickLinks?: boolean;
oneClickMode?: OneClickMode;
}
// Unit is percentage from the middle of the element

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@ -99,6 +99,7 @@ export const cloudItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@ -94,6 +94,8 @@ export const droneFrontItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
// Called when data changes

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@ -93,6 +93,8 @@ export const droneSideItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
// Called when data changes

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@ -98,6 +98,8 @@ export const droneTopItem: CanvasElementItem = {
fixed: 'transparent',
},
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),
// Called when data changes

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@ -106,6 +106,7 @@ export const ellipseItem: CanvasElementItem<CanvasElementConfig, CanvasElementDa
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { isString } from 'lodash';
import { CSSProperties } from 'react';
import { LinkModel } from '@grafana/data';
import { LinkModel, OneClickMode } from '@grafana/data';
import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } from '@grafana/schema';
import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG';
import { getPublicOrAbsoluteUrl } from 'app/features/dimensions';
@ -80,6 +80,7 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -3,7 +3,13 @@ import { useCallback } from 'react';
import { useObservable } from 'react-use';
import { of } from 'rxjs';
import { DataFrame, FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem } from '@grafana/data';
import {
DataFrame,
FieldNamePickerConfigSettings,
GrafanaTheme2,
OneClickMode,
StandardEditorsRegistryItem,
} from '@grafana/data';
import { TextDimensionMode } from '@grafana/schema';
import { usePanelContext, useStyles2 } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
@ -175,6 +181,7 @@ export const metricValueItem: CanvasElementItem<TextConfig, TextData> = {
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@ -99,6 +99,7 @@ export const parallelogramItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { PureComponent } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
@ -72,6 +72,7 @@ export const rectangleItem: CanvasElementItem<TextConfig, TextData> = {
fixed: defaultBgColor,
},
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { GrafanaTheme2, LinkModel, OneClickMode } from '@grafana/data';
import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema';
import config from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
@ -83,6 +83,7 @@ export const serverItem: CanvasElementItem<ServerConfig, ServerData> = {
config: {
type: ServerType.Single,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -4,7 +4,7 @@ import * as React from 'react';
import { useObservable } from 'react-use';
import { of } from 'rxjs';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { DataFrame, GrafanaTheme2, OneClickMode } from '@grafana/data';
import { Input, usePanelContext, useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@ -148,6 +148,7 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OneClickMode } from '@grafana/data';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@ -100,6 +100,7 @@ export const triangleItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { GrafanaTheme2, LinkModel, OneClickMode } from '@grafana/data';
import { ScalarDimensionConfig } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions';
@ -90,6 +90,7 @@ export const windTurbineItem: CanvasElementItem = {
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
},
oneClickMode: options?.oneClickMode ?? OneClickMode.Off,
links: options?.links ?? [],
}),

View File

@ -1,8 +1,8 @@
import { CSSProperties } from 'react';
import * as React from 'react';
import { CSSProperties } from 'react';
import { OnDrag, OnResize, OnRotate } from 'react-moveable/declaration/types';
import { FieldType, getLinksSupplier, LinkModel, ValueLinkConfig } from '@grafana/data';
import { FieldType, getLinksSupplier, LinkModel, OneClickMode, ValueLinkConfig } from '@grafana/data';
import { LayerElement } from 'app/core/components/Layers/types';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions';
@ -62,6 +62,7 @@ export class ElementState implements LayerElement {
options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 };
options.background = options.background ?? { color: { fixed: 'transparent' } };
options.border = options.border ?? { color: { fixed: 'dark-green' } };
options.oneClickMode = options.oneClickMode ?? OneClickMode.Off;
const scene = this.getScene();
if (!options.name) {
const newName = scene?.getNextElementName();
@ -584,13 +585,17 @@ export class ElementState implements LayerElement {
handleMouseEnter = (event: React.MouseEvent, isSelected: boolean | undefined) => {
const scene = this.getScene();
if (!scene?.isEditingEnabled && !scene?.tooltip?.isOpen && !this.options.oneClickLinks) {
const shouldHandleTooltip =
!scene?.isEditingEnabled && !scene?.tooltip?.isOpen && this.options.oneClickMode === OneClickMode.Off;
if (shouldHandleTooltip) {
this.handleTooltip(event);
} else if (!isSelected) {
scene?.connections.handleMouseEnter(event);
}
if (this.options.oneClickLinks && this.div && this.options.links && this.options.links.length > 0) {
const shouldHandleOneClickLink =
this.options.oneClickMode === OneClickMode.Link && this.options.links && this.options.links.length > 0;
if (shouldHandleOneClickLink && this.div) {
const primaryDataLink = this.getPrimaryDataLink();
if (primaryDataLink) {
this.div.style.cursor = 'pointer';
@ -602,8 +607,7 @@ export class ElementState implements LayerElement {
getPrimaryDataLink = () => {
if (this.getLinks) {
const links = this.getLinks({ valueRowIndex: getRowIndex(this.data.field, this.getScene()!) });
const primaryDataLink = links.find((link: LinkModel) => link.sortIndex === 0);
return primaryDataLink ?? links[0];
return links[0];
}
return undefined;
@ -623,11 +627,11 @@ export class ElementState implements LayerElement {
handleMouseLeave = (event: React.MouseEvent) => {
const scene = this.getScene();
if (scene?.tooltipCallback && !scene?.tooltip?.isOpen && !this.options.oneClickLinks) {
if (scene?.tooltipCallback && !scene?.tooltip?.isOpen && this.options.oneClickMode === OneClickMode.Off) {
scene.tooltipCallback(undefined);
}
if (this.options.oneClickLinks && this.div) {
if (this.options.oneClickMode !== OneClickMode.Off && this.div) {
this.div.style.cursor = 'auto';
this.div.title = '';
}
@ -635,7 +639,7 @@ export class ElementState implements LayerElement {
onElementClick = (event: React.MouseEvent) => {
// If one-click access is enabled, open the primary link
if (this.options.oneClickLinks) {
if (this.options.oneClickMode === OneClickMode.Link) {
let primaryDataLink = this.getPrimaryDataLink();
if (primaryDataLink) {
window.open(primaryDataLink.href, primaryDataLink.target);

View File

@ -83,9 +83,6 @@ export const CanvasTooltip = ({ scene }: Props) => {
});
}
// sort element data links
links.sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0));
return (
<>
{scene.tooltip?.element && scene.tooltip.anchorPoint && (

View File

@ -1,16 +1,11 @@
import { StandardEditorProps, DataLink, VariableSuggestionsScope } from '@grafana/data';
import { StandardEditorProps, DataLink, VariableSuggestionsScope, OneClickMode } from '@grafana/data';
import { DataLinksInlineEditor } from '@grafana/ui';
import { CanvasElementOptions } from '../../panelcfg.gen';
import { CanvasElementOptions } from 'app/features/canvas/element';
type Props = StandardEditorProps<DataLink[], CanvasElementOptions>;
export function DataLinksEditor({ value, onChange, item, context }: Props) {
if (!value) {
value = [];
}
const settings = item.settings;
const oneClickMode = item.settings?.oneClickMode;
return (
<DataLinksInlineEditor
@ -18,7 +13,7 @@ export function DataLinksEditor({ value, onChange, item, context }: Props) {
onChange={onChange}
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
data={[]}
oneClickEnabled={settings?.oneClickLinks}
showOneClick={oneClickMode === OneClickMode.Link}
/>
);
}

View File

@ -1,5 +1,6 @@
import { get as lodashGet } from 'lodash';
import { capitalize, get as lodashGet } from 'lodash';
import { OneClickMode } from '@grafana/data';
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
import { CanvasElementOptions } from 'app/features/canvas/element';
import {
@ -119,6 +120,20 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
optionBuilder.addBorder(builder, ctx);
}
builder.addRadio({
category: ['Data links'],
path: 'oneClickMode',
name: 'One-click',
description: 'When enabled, a single click opens the first link',
settings: {
options: [
{ value: OneClickMode.Off, label: capitalize(OneClickMode.Off) },
{ value: OneClickMode.Link, label: capitalize(OneClickMode.Link) },
],
},
defaultValue: OneClickMode.Off,
});
optionBuilder.addDataLinks(builder, ctx);
},
};

View File

@ -1,6 +1,6 @@
import { capitalize } from 'lodash';
import { FieldType, standardEditorsRegistry } from '@grafana/data';
import { FieldType } from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
import { ConnectionDirection } from 'app/features/canvas/element';
import { SVGElements } from 'app/features/canvas/runtime/element';
@ -209,23 +209,13 @@ export const optionBuilder: OptionSuppliers = {
},
addDataLinks: (builder, context) => {
const category = ['Data links'];
builder
.addCustomEditor({
category,
id: 'enableOneClick',
path: 'oneClickLinks',
name: 'One-click',
description: 'When enabled, the top link in the list below works with a single click',
editor: standardEditorsRegistry.get('boolean').editor,
})
.addCustomEditor({
category,
id: 'dataLinks',
path: 'links',
name: '',
editor: DataLinksEditor,
settings: context.options,
});
builder.addCustomEditor({
category: ['Data links'],
id: 'dataLinks',
path: 'links',
name: 'Links',
editor: DataLinksEditor,
settings: context.options,
});
},
};

View File

@ -1,4 +1,4 @@
import { DataLink, DynamicConfigValue, FieldMatcherID, PanelModel } from '@grafana/data';
import { DataLink, DynamicConfigValue, FieldMatcherID, PanelModel, OneClickMode } from '@grafana/data';
import { CanvasElementOptions } from 'app/features/canvas/element';
import { Options } from './panelcfg.gen';
@ -43,7 +43,8 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
}
}
if (parseFloat(pluginVersion) <= 11.2) {
if (parseFloat(pluginVersion) <= 11.3) {
// migrate links from field name overrides to elements
for (let idx = 0; idx < panel.fieldConfig.overrides.length; idx++) {
const override = panel.fieldConfig.overrides[idx];
@ -66,6 +67,17 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
}
}
}
// migrate oneClickLinks to oneClickMode
const root = panel.options?.root;
if (root?.elements) {
for (const element of root.elements) {
if (element.oneClickLinks) {
element.oneClickMode = OneClickMode.Link;
delete element.oneClickLinks;
}
}
}
}
return panel.options;

View File

@ -87,7 +87,6 @@ composableKinds: PanelCfg: {
background?: BackgroundConfig
border?: LineConfig
connections?: [...CanvasConnection]
oneClickLinks?: bool
} @cuetsy(kind="interface")
Options: {

View File

@ -102,7 +102,6 @@ export interface CanvasElementOptions {
connections?: Array<CanvasConnection>;
constraint?: Constraint;
name: string;
oneClickLinks?: boolean;
placement?: Placement;
type: string;
}