Canvas: Element level data links (#89079)
This commit is contained in:
+2
-1
@@ -601,7 +601,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItem.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
|
||||
@@ -248,7 +248,9 @@ If you want to add all of the current dashboard's variables to the URL, then use
|
||||
|
||||
To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables.
|
||||
|
||||
1. If you want the link to open in a new tab, then toggle the **Open in a new tab** switch.
|
||||
1. If you want the link to open in a new tab, toggle the **Open in a new tab** switch.
|
||||
1. Click **Save** to save changes and close the dialog box.
|
||||
1. Click **Save dashboard**.
|
||||
1. Click **Back to dashboard** and then **Exit edit**.
|
||||
|
||||
If you add multiple data links, you can control the order in which they appear in the visualization. To do this, click and drag the data link to the desired position.
|
||||
|
||||
@@ -166,25 +166,36 @@ The inline editing toggle lets you lock or unlock the canvas. When turned off th
|
||||
|
||||
Canvases support [data links](ref:data-links) for all elements except drone and button elements. You can add a data link by following these steps:
|
||||
|
||||
1. Set an element to be tied to a field value.
|
||||
1. Turn off the inline editing toggle.
|
||||
1. Create an override for **Fields with name** and select the element field name from the list.
|
||||
1. Click the **+ Add override property** button.
|
||||
1. Select `Datalinks > Datalinks` from the list.
|
||||
1. Click **+Add link** add a title and URL for the data link.
|
||||
1. Hover over the element to display the data link tooltip.
|
||||
1. Click on the element to be able to open the data link.
|
||||
1. Enable inline editing.
|
||||
1. Click the element you 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. Click **Add link**.
|
||||
1. In the dialog box that opens, enter a **Title**. This is a human-readable label for the link, which will be displayed in the UI.
|
||||
1. Enter the **URL** or variable to which you want to link.
|
||||
|
||||
If multiple elements use the same field name, and you want to control which elements display the data link, you can create a unique field name using the [add field from calculation transform](ref:add-field-from-calculation-transform). The alias you create in the transformation will appear as a field you can use with an element.
|
||||
To add a data link variable, click in the **URL** field and enter `$` or press Ctrl+Space or Cmd+Space to see a list of available variables.
|
||||
|
||||
1. In the panel editor for the canvas, click the **Transform** tab.
|
||||
1. Select **Add field from calculation** from the list of transformations, or click **+ Add transformation** to display the list first.
|
||||
1. Choose **Reduce row** from the dropdown and click the field name that you want to use for the element.
|
||||
1. Select **All Values** from the **Calculation** dropdown.
|
||||
1. Add an alias for the field name.
|
||||
1. Reference the new unique field alias to create the element and field override.
|
||||
1. If you want the link to open in a new tab, toggle the **Open in a new tab** switch.
|
||||
1. Click **Save** to save changes and close the dialog box.
|
||||
1. Disable inline editing.
|
||||
|
||||
{{< video-embed src="/media/docs/grafana/canvas-data-links-9-4-0.mp4" max-width="750px" caption="Data links demo" >}}
|
||||
If you add multiple data links, you can control the order in which they appear in the visualization. To do this, click and drag the data link to the desired position.
|
||||
|
||||
#### One-click data link
|
||||
|
||||
You can configure a canvas data link to open with a single click on the element. To enable this feature, follow these steps:
|
||||
|
||||
1. Enable inline editing.
|
||||
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. 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" >}}
|
||||
|
||||
## Panel options
|
||||
|
||||
|
||||
@@ -479,6 +479,7 @@ 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,
|
||||
@@ -494,6 +495,7 @@ export const getLinksSupplier =
|
||||
title: replaceVariables(link.title || '', dataLinkScopedVars),
|
||||
target: link.targetBlank ? '_blank' : undefined,
|
||||
origin: field,
|
||||
sortIndex: link.sortIndex,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface DataLink<T extends DataQuery = any> {
|
||||
internal?: InternalDataLink<T>;
|
||||
|
||||
origin?: DataLinkConfigOrigin;
|
||||
sortIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +99,7 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -104,6 +104,7 @@ export interface CanvasElementOptions {
|
||||
connections?: Array<CanvasConnection>;
|
||||
constraint?: Constraint;
|
||||
name: string;
|
||||
oneClickLinks?: boolean;
|
||||
placement?: Placement;
|
||||
type: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@grafana/e2e-selectors": "11.2.0-pre",
|
||||
"@grafana/faro-web-sdk": "^1.3.6",
|
||||
"@grafana/schema": "11.2.0-pre",
|
||||
"@hello-pangea/dnd": "16.6.0",
|
||||
"@leeoniya/ufuzzy": "1.0.14",
|
||||
"@monaco-editor/react": "4.6.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
|
||||
+93
-21
@@ -1,11 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useState } from 'react';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { DataFrame, DataLink, GrafanaTheme2, VariableSuggestion } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { Button } from '../../Button/Button';
|
||||
import { Button } from '../../Button';
|
||||
import { Modal } from '../../Modal/Modal';
|
||||
|
||||
import { DataLinkEditorModalContent } from './DataLinkEditorModalContent';
|
||||
@@ -16,14 +17,27 @@ interface DataLinksInlineEditorProps {
|
||||
onChange: (links: DataLink[]) => void;
|
||||
getSuggestions: () => VariableSuggestion[];
|
||||
data: DataFrame[];
|
||||
oneClickEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const DataLinksInlineEditor = ({ links, onChange, getSuggestions, data }: DataLinksInlineEditorProps) => {
|
||||
export const DataLinksInlineEditor = ({
|
||||
links,
|
||||
onChange,
|
||||
getSuggestions,
|
||||
data,
|
||||
oneClickEnabled = 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 ?? []);
|
||||
}, [links]);
|
||||
|
||||
const styles = useStyles2(getDataLinksInlineEditorStyles);
|
||||
const linksSafe: DataLink[] = links ?? [];
|
||||
const isEditing = editIndex !== null;
|
||||
|
||||
const onDataLinkChange = (index: number, link: DataLink) => {
|
||||
@@ -62,25 +76,70 @@ export const DataLinksInlineEditor = ({ links, onChange, getSuggestions, data }:
|
||||
onChange(update);
|
||||
};
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!links || !result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const copy = [...linksSafe];
|
||||
const link = copy[result.source.index];
|
||||
link.sortIndex = result.destination.index;
|
||||
|
||||
const swapLink = copy[result.destination.index];
|
||||
swapLink.sortIndex = result.source.index;
|
||||
|
||||
copy.splice(result.source.index, 1);
|
||||
copy.splice(result.destination.index, 0, link);
|
||||
|
||||
setLinksSafe(copy);
|
||||
onChange(linksSafe);
|
||||
};
|
||||
|
||||
const renderFirstLink = (linkJSX: ReactNode) => {
|
||||
if (oneClickEnabled) {
|
||||
return (
|
||||
<div className={styles.oneClickOverlay}>
|
||||
<span className={styles.oneClickSpan}>One-click</span>
|
||||
{linkJSX}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return linkJSX;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{linksSafe.length > 0 && (
|
||||
<div className={styles.wrapper}>
|
||||
{linksSafe.map((l, i) => {
|
||||
return (
|
||||
<DataLinksListItem
|
||||
key={`${l.title}/${i}`}
|
||||
index={i}
|
||||
link={l}
|
||||
onChange={onDataLinkChange}
|
||||
onEdit={() => setEditIndex(i)}
|
||||
onRemove={() => onDataLinkRemove(i)}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="sortable-links" direction="vertical">
|
||||
{(provided) => (
|
||||
<div className={styles.wrapper} ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{linksSafe.map((link, idx) => {
|
||||
const key = `${link.title}/${idx}`;
|
||||
|
||||
const linkJSX = (
|
||||
<DataLinksListItem
|
||||
key={key}
|
||||
index={idx}
|
||||
link={link}
|
||||
onChange={onDataLinkChange}
|
||||
onEdit={() => setEditIndex(idx)}
|
||||
onRemove={() => onDataLinkRemove(idx)}
|
||||
data={data}
|
||||
itemKey={key}
|
||||
/>
|
||||
);
|
||||
|
||||
if (idx === 0) {
|
||||
return renderFirstLink(linkJSX);
|
||||
}
|
||||
|
||||
return linkJSX;
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
|
||||
{isEditing && editIndex !== null && (
|
||||
<Modal
|
||||
@@ -113,4 +172,17 @@ const getDataLinksInlineEditorStyles = (theme: GrafanaTheme2) => ({
|
||||
wrapper: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
oneClickOverlay: css({
|
||||
height: 'auto',
|
||||
border: `1px dashed ${theme.colors.border.medium}`,
|
||||
paddingBottom: 10,
|
||||
fontSize: 10,
|
||||
color: theme.colors.text.link,
|
||||
}),
|
||||
oneClickSpan: css({
|
||||
padding: 10,
|
||||
// Negates the padding on the span from moving the underlying link
|
||||
marginBottom: -10,
|
||||
display: 'inline-block',
|
||||
}),
|
||||
});
|
||||
|
||||
+15
-1
@@ -1,3 +1,4 @@
|
||||
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { DataLinksListItem, DataLinksListItemProps } from './DataLinksListItem';
|
||||
@@ -17,10 +18,23 @@ function setupTestContext(options: Partial<DataLinksListItemProps>) {
|
||||
onChange: jest.fn(),
|
||||
onEdit: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
itemKey: 'itemKey',
|
||||
};
|
||||
|
||||
const onDragEnd = jest.fn();
|
||||
|
||||
const props = { ...defaults, ...options };
|
||||
const { rerender } = render(<DataLinksListItem {...props} />);
|
||||
const { rerender } = render(
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="sortable-links" direction="vertical">
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps}>
|
||||
<DataLinksListItem {...props} />
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
|
||||
return { rerender, props };
|
||||
}
|
||||
|
||||
+73
-32
@@ -1,10 +1,12 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Draggable } from '@hello-pangea/dnd';
|
||||
|
||||
import { DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { isCompactUrl } from '../../../utils/dataLinks';
|
||||
import { FieldValidationMessage } from '../../Forms/FieldValidationMessage';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { IconButton } from '../../IconButton/IconButton';
|
||||
|
||||
export interface DataLinksListItemProps {
|
||||
@@ -15,9 +17,10 @@ export interface DataLinksListItemProps {
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
isEditing?: boolean;
|
||||
itemKey: string;
|
||||
}
|
||||
|
||||
export const DataLinksListItem = ({ link, onEdit, onRemove }: DataLinksListItemProps) => {
|
||||
export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: DataLinksListItemProps) => {
|
||||
const styles = useStyles2(getDataLinkListItemStyles);
|
||||
const { title = '', url = '' } = link;
|
||||
|
||||
@@ -27,50 +30,64 @@ export const DataLinksListItem = ({ link, onEdit, onRemove }: DataLinksListItemP
|
||||
const isCompactExploreUrl = isCompactUrl(url);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.titleWrapper}>
|
||||
<div className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}>
|
||||
{hasTitle ? title : 'Data link title not provided'}
|
||||
</div>
|
||||
<div className={styles.actionButtons}>
|
||||
<IconButton name="pen" onClick={onEdit} tooltip="Edit data link title" />
|
||||
<IconButton name="times" onClick={onRemove} tooltip="Remove data link title" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}
|
||||
title={url}
|
||||
>
|
||||
{hasUrl ? url : 'Data link url not provided'}
|
||||
</div>
|
||||
{isCompactExploreUrl && (
|
||||
<FieldValidationMessage>Explore data link may not work in the future. Please edit.</FieldValidationMessage>
|
||||
<Draggable key={itemKey} draggableId={itemKey} index={index}>
|
||||
{(provided) => (
|
||||
<>
|
||||
<div
|
||||
className={cx(styles.wrapper, styles.dragRow)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
key={index}
|
||||
>
|
||||
<div className={cx(styles.dragHandle, styles.icons)} {...provided.dragHandleProps}>
|
||||
<Icon name="draggabledots" size="lg" />
|
||||
</div>
|
||||
|
||||
<div className={styles.linkDetails}>
|
||||
<div className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}>
|
||||
{hasTitle ? title : 'Data link title not provided'}
|
||||
</div>
|
||||
<div
|
||||
className={cx(styles.url, !hasUrl && styles.notConfigured, isCompactExploreUrl && styles.errored)}
|
||||
title={url}
|
||||
>
|
||||
{hasUrl ? url : 'Data link url not provided'}
|
||||
</div>
|
||||
{isCompactExploreUrl && (
|
||||
<FieldValidationMessage>
|
||||
Explore data link may not work in the future. Please edit.
|
||||
</FieldValidationMessage>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<IconButton name="pen" onClick={onEdit} tooltip="Edit data link title" />
|
||||
<IconButton name="times" onClick={onRemove} tooltip="Remove data link title" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
||||
const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
wrapper: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
marginBottom: theme.spacing(2),
|
||||
padding: '10px 0 0 10px',
|
||||
'&:last-child': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
}),
|
||||
linkDetails: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
titleWrapper: css({
|
||||
label: 'data-links-list-item-title',
|
||||
justifyContent: 'space-between',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
actionButtons: css({
|
||||
marginLeft: theme.spacing(1),
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
}),
|
||||
errored: css({
|
||||
color: theme.colors.error.text,
|
||||
@@ -92,5 +109,29 @@ const getDataLinkListItemStyles = (theme: GrafanaTheme2) => {
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '90%',
|
||||
}),
|
||||
dragRow: css({
|
||||
position: 'relative',
|
||||
}),
|
||||
icons: css({
|
||||
display: 'flex',
|
||||
padding: 6,
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}),
|
||||
dragHandle: css({
|
||||
cursor: 'grab',
|
||||
// create focus ring around the whole row when the drag handle is tab-focused
|
||||
// needs position: relative on the drag row to work correctly
|
||||
'&:focus-visible&:after': {
|
||||
bottom: 0,
|
||||
content: '""',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
outline: `2px solid ${theme.colors.primary.main}`,
|
||||
outlineOffset: '-2px',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import { RegistryItem } from '@grafana/data';
|
||||
import { DataLink, RegistryItem } from '@grafana/data';
|
||||
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
|
||||
import { ColorDimensionConfig, ScaleDimensionConfig } from '@grafana/schema';
|
||||
import { config } from 'app/core/config';
|
||||
@@ -32,6 +32,8 @@ export interface CanvasElementOptions<TConfig = any> {
|
||||
background?: BackgroundConfig;
|
||||
border?: LineConfig;
|
||||
connections?: CanvasConnection[];
|
||||
links?: DataLink[];
|
||||
oneClickLinks?: boolean;
|
||||
}
|
||||
|
||||
// Unit is percentage from the middle of the element
|
||||
|
||||
@@ -6,7 +6,6 @@ import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
|
||||
import { getDataLinks } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import {
|
||||
CanvasElementItem,
|
||||
@@ -100,6 +99,7 @@ export const cloudItem: CanvasElementItem = {
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
// Called when data changes
|
||||
@@ -118,8 +118,6 @@ export const cloudItem: CanvasElementItem = {
|
||||
data.color = dimensionContext.getColor(textConfig.color).value();
|
||||
}
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.text);
|
||||
|
||||
const { background, border } = elementOptions;
|
||||
data.backgroundColor = background?.color ? dimensionContext.getColor(background.color).value() : defaultBgColor;
|
||||
data.borderColor = border?.color ? dimensionContext.getColor(border.color).value() : defaultBgColor;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { config } from 'app/core/config';
|
||||
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 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import {
|
||||
CanvasElementItem,
|
||||
@@ -107,6 +106,7 @@ export const ellipseItem: CanvasElementItem<CanvasElementConfig, CanvasElementDa
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<CanvasElementConfig>) => {
|
||||
@@ -124,8 +124,6 @@ export const ellipseItem: CanvasElementItem<CanvasElementConfig, CanvasElementDa
|
||||
data.color = dimensionContext.getColor(textConfig.color).value();
|
||||
}
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.text);
|
||||
|
||||
const { background, border } = elementOptions;
|
||||
data.backgroundColor = background?.color ? dimensionContext.getColor(background.color).value() : defaultBgColor;
|
||||
data.borderColor = border?.color ? dimensionContext.getColor(border.color).value() : defaultBgColor;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getPublicOrAbsoluteUrl } from 'app/features/dimensions';
|
||||
import { DimensionContext } from 'app/features/dimensions/context';
|
||||
import { ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors';
|
||||
import { LineConfig } from 'app/plugins/panel/canvas/panelcfg.gen';
|
||||
import { getDataLinks } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultBgColor } from '../element';
|
||||
|
||||
@@ -81,6 +80,7 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
|
||||
left: options?.placement?.left ?? 100,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
// Called when data changes
|
||||
@@ -107,8 +107,6 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
|
||||
}
|
||||
}
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.path);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { frameHasName, getFrameFieldsDisplayNames } from '@grafana/ui/src/compon
|
||||
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 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import {
|
||||
CanvasElementItem,
|
||||
@@ -176,6 +175,7 @@ export const metricValueItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
left: options?.placement?.left ?? 100,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TextConfig>) => {
|
||||
@@ -193,8 +193,6 @@ export const metricValueItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
data.color = dimensionContext.getColor(textConfig.color).value();
|
||||
}
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.text);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
|
||||
import { getDataLinks } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import {
|
||||
CanvasElementItem,
|
||||
@@ -100,6 +99,7 @@ export const parallelogramItem: CanvasElementItem = {
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
// Called when data changes
|
||||
@@ -118,8 +118,6 @@ export const parallelogramItem: CanvasElementItem = {
|
||||
data.color = dimensionContext.getColor(textConfig.color).value();
|
||||
}
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.text);
|
||||
|
||||
const { background, border } = elementOptions;
|
||||
data.backgroundColor = background?.color ? dimensionContext.getColor(background.color).value() : defaultBgColor;
|
||||
data.borderColor = border?.color ? dimensionContext.getColor(border.color).value() : defaultBgColor;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { config } from 'app/core/config';
|
||||
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 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import {
|
||||
CanvasElementItem,
|
||||
@@ -73,6 +72,7 @@ export const rectangleItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
fixed: defaultBgColor,
|
||||
},
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
// Called when data changes
|
||||
@@ -91,8 +91,6 @@ export const rectangleItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
data.color = dimensionContext.getColor(textConfig.color).value();
|
||||
}
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.text);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema';
|
||||
import config from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions';
|
||||
import { ColorDimensionEditor, ScalarDimensionEditor } from 'app/features/dimensions/editors';
|
||||
import { getDataLinks } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import { CanvasElementItem, CanvasElementOptions, CanvasElementProps } from '../../element';
|
||||
|
||||
@@ -84,6 +83,7 @@ export const serverItem: CanvasElementItem<ServerConfig, ServerData> = {
|
||||
config: {
|
||||
type: ServerType.Single,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
// Called when data changes
|
||||
@@ -99,8 +99,6 @@ export const serverItem: CanvasElementItem<ServerConfig, ServerData> = {
|
||||
type: serverConfig?.type ?? ServerType.Single,
|
||||
};
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.statusColor);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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, CanvasElementOptions, CanvasElementProps, defaultThemeTextColor } from '../element';
|
||||
import { ElementState } from '../runtime/element';
|
||||
import { Align, TextConfig, TextData, VAlign } from '../types';
|
||||
@@ -149,6 +148,7 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TextConfig>) => {
|
||||
@@ -166,8 +166,6 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = {
|
||||
data.color = dimensionContext.getColor(textConfig.color).value();
|
||||
}
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.text);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { config } from 'app/core/config';
|
||||
import { DimensionContext } from 'app/features/dimensions';
|
||||
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
|
||||
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
|
||||
import { getDataLinks } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import {
|
||||
CanvasElementItem,
|
||||
@@ -101,6 +100,7 @@ export const triangleItem: CanvasElementItem = {
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
// Called when data changes
|
||||
@@ -119,8 +119,6 @@ export const triangleItem: CanvasElementItem = {
|
||||
data.color = dimensionContext.getColor(textConfig.color).value();
|
||||
}
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, data.text);
|
||||
|
||||
const { background, border } = elementOptions;
|
||||
data.backgroundColor = background?.color ? dimensionContext.getColor(background.color).value() : defaultBgColor;
|
||||
data.borderColor = border?.color ? dimensionContext.getColor(border.color).value() : defaultBgColor;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ScalarDimensionConfig } from '@grafana/schema';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { DimensionContext } from 'app/features/dimensions';
|
||||
import { ScalarDimensionEditor } from 'app/features/dimensions/editors';
|
||||
import { getDataLinks } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import { CanvasElementItem, CanvasElementOptions, CanvasElementProps, defaultBgColor } from '../element';
|
||||
|
||||
@@ -91,6 +90,7 @@ export const windTurbineItem: CanvasElementItem = {
|
||||
left: options?.placement?.left,
|
||||
rotation: options?.placement?.rotation ?? 0,
|
||||
},
|
||||
links: options?.links ?? [],
|
||||
}),
|
||||
|
||||
// Called when data changes
|
||||
@@ -101,8 +101,6 @@ export const windTurbineItem: CanvasElementItem = {
|
||||
rpm: windTurbineConfig?.rpm ? dimensionContext.getScalar(windTurbineConfig.rpm).value() : 0,
|
||||
};
|
||||
|
||||
data.links = getDataLinks(dimensionContext, elementOptions, `${data.rpm}`);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CSSProperties } from 'react';
|
||||
import * as React from 'react';
|
||||
import { OnDrag, OnResize, OnRotate } from 'react-moveable/declaration/types';
|
||||
|
||||
import { FieldType, getLinksSupplier, LinkModel, 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';
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
Placement,
|
||||
VerticalConstraint,
|
||||
} from 'app/plugins/panel/canvas/panelcfg.gen';
|
||||
import { getConnectionsByTarget, isConnectionTarget } from 'app/plugins/panel/canvas/utils';
|
||||
import { getConnectionsByTarget, getRowIndex, isConnectionTarget } from 'app/plugins/panel/canvas/utils';
|
||||
|
||||
import { CanvasElementItem, CanvasElementOptions } from '../element';
|
||||
import { canvasElementRegistry } from '../registry';
|
||||
@@ -42,6 +43,8 @@ export class ElementState implements LayerElement {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data?: any; // depends on the type
|
||||
|
||||
getLinks?: (config: ValueLinkConfig) => LinkModel[];
|
||||
|
||||
constructor(
|
||||
public item: CanvasElementItem,
|
||||
public options: CanvasElementOptions,
|
||||
@@ -366,6 +369,34 @@ export class ElementState implements LayerElement {
|
||||
this.revId++; // rerender
|
||||
}
|
||||
|
||||
const scene = this.getScene();
|
||||
const frames = scene?.data?.series;
|
||||
|
||||
if (frames) {
|
||||
const defaultField = {
|
||||
name: 'Default field',
|
||||
type: FieldType.string,
|
||||
config: { links: this.options.links ?? [] },
|
||||
values: [],
|
||||
};
|
||||
|
||||
this.getLinks = getLinksSupplier(
|
||||
frames[0],
|
||||
defaultField,
|
||||
{
|
||||
__dataContext: {
|
||||
value: {
|
||||
data: frames,
|
||||
field: defaultField,
|
||||
frame: frames[0],
|
||||
frameIndex: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
scene?.panel.props.replaceVariables!
|
||||
);
|
||||
}
|
||||
|
||||
const { background, border } = this.options;
|
||||
const css: CSSProperties = {};
|
||||
if (background) {
|
||||
@@ -552,11 +583,30 @@ export class ElementState implements LayerElement {
|
||||
|
||||
handleMouseEnter = (event: React.MouseEvent, isSelected: boolean | undefined) => {
|
||||
const scene = this.getScene();
|
||||
if (!scene?.isEditingEnabled && !scene?.tooltip?.isOpen) {
|
||||
|
||||
if (!scene?.isEditingEnabled && !scene?.tooltip?.isOpen && !this.options.oneClickLinks) {
|
||||
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 primaryDataLink = this.getPrimaryDataLink();
|
||||
if (primaryDataLink) {
|
||||
this.div.style.cursor = 'pointer';
|
||||
this.div.title = `Navigate to ${primaryDataLink.title === '' ? 'data link' : primaryDataLink.title}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 undefined;
|
||||
};
|
||||
|
||||
handleTooltip = (event: React.MouseEvent) => {
|
||||
@@ -573,14 +623,27 @@ export class ElementState implements LayerElement {
|
||||
|
||||
handleMouseLeave = (event: React.MouseEvent) => {
|
||||
const scene = this.getScene();
|
||||
if (scene?.tooltipCallback && !scene?.tooltip?.isOpen) {
|
||||
if (scene?.tooltipCallback && !scene?.tooltip?.isOpen && !this.options.oneClickLinks) {
|
||||
scene.tooltipCallback(undefined);
|
||||
}
|
||||
|
||||
if (this.options.oneClickLinks && this.div) {
|
||||
this.div.style.cursor = 'auto';
|
||||
this.div.title = '';
|
||||
}
|
||||
};
|
||||
|
||||
onElementClick = (event: React.MouseEvent) => {
|
||||
this.handleTooltip(event);
|
||||
this.onTooltipCallback();
|
||||
// If one-click access is enabled, open the primary link
|
||||
if (this.options.oneClickLinks) {
|
||||
let primaryDataLink = this.getPrimaryDataLink();
|
||||
if (primaryDataLink) {
|
||||
window.open(primaryDataLink.href, primaryDataLink.target);
|
||||
}
|
||||
} else {
|
||||
this.handleTooltip(event);
|
||||
this.onTooltipCallback();
|
||||
}
|
||||
};
|
||||
|
||||
onElementKeyDown = (event: React.KeyboardEvent) => {
|
||||
|
||||
@@ -281,7 +281,9 @@ export class Scene {
|
||||
};
|
||||
|
||||
render() {
|
||||
const isTooltipValid = (this.tooltip?.element?.data?.links?.length ?? 0) > 0 || this.tooltip?.element?.data?.field;
|
||||
const isTooltipValid =
|
||||
(this.tooltip?.element?.getLinks && this.tooltip?.element?.getLinks({}).length > 0) ||
|
||||
this.tooltip?.element?.data?.field;
|
||||
const canShowElementTooltip = !this.isEditingEnabled && isTooltipValid;
|
||||
|
||||
const sceneDiv = (
|
||||
|
||||
@@ -3,7 +3,14 @@ import { useDialog } from '@react-aria/dialog';
|
||||
import { useOverlay } from '@react-aria/overlays';
|
||||
import { createRef } from 'react';
|
||||
|
||||
import { FieldType, GrafanaTheme2, formattedValueToString, getFieldDisplayName } from '@grafana/data/src';
|
||||
import {
|
||||
Field,
|
||||
LinkModel,
|
||||
FieldType,
|
||||
GrafanaTheme2,
|
||||
formattedValueToString,
|
||||
getFieldDisplayName,
|
||||
} from '@grafana/data/src';
|
||||
import { Portal, useStyles2, VizTooltipContainer } from '@grafana/ui';
|
||||
import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent';
|
||||
import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter';
|
||||
@@ -12,6 +19,8 @@ import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types';
|
||||
import { CloseButton } from '@grafana/ui/src/components/uPlot/plugins/CloseButton';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
|
||||
import { getRowIndex } from '../utils';
|
||||
|
||||
interface Props {
|
||||
scene: Scene;
|
||||
}
|
||||
@@ -60,6 +69,23 @@ export const CanvasTooltip = ({ scene }: Props) => {
|
||||
: []),
|
||||
];
|
||||
|
||||
const links: Array<LinkModel<Field>> = [];
|
||||
const linkLookup = new Set<string>();
|
||||
|
||||
const elementHasLinks = (element.options.links?.length ?? 0) > 0;
|
||||
if (elementHasLinks && element.getLinks) {
|
||||
element.getLinks({ valueRowIndex: getRowIndex(element.data.field, scene) }).forEach((link) => {
|
||||
const key = `${link.title}/${link.href}`;
|
||||
if (!linkLookup.has(key)) {
|
||||
links.push(link);
|
||||
linkLookup.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// sort element data links
|
||||
links.sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0));
|
||||
|
||||
return (
|
||||
<>
|
||||
{scene.tooltip?.element && scene.tooltip.anchorPoint && (
|
||||
@@ -74,7 +100,7 @@ export const CanvasTooltip = ({ scene }: Props) => {
|
||||
{scene.tooltip.isOpen && <CloseButton style={{ zIndex: 1 }} onClick={onClose} />}
|
||||
<VizTooltipHeader item={headerItem} isPinned={scene.tooltip.isOpen!} />
|
||||
{element.data.text && <VizTooltipContent items={contentItems} isPinned={scene.tooltip.isOpen!} />}
|
||||
<VizTooltipFooter dataLinks={element.data?.links} />
|
||||
{links.length > 0 && <VizTooltipFooter dataLinks={links} />}
|
||||
</section>
|
||||
</VizTooltipContainer>
|
||||
</Portal>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { StandardEditorProps, DataLink, VariableSuggestionsScope } from '@grafana/data';
|
||||
import { DataLinksInlineEditor } from '@grafana/ui';
|
||||
|
||||
import { CanvasElementOptions } from '../../panelcfg.gen';
|
||||
|
||||
type Props = StandardEditorProps<DataLink[], CanvasElementOptions>;
|
||||
|
||||
export function DataLinksEditor({ value, onChange, item, context }: Props) {
|
||||
if (!value) {
|
||||
value = [];
|
||||
}
|
||||
|
||||
const settings = item.settings;
|
||||
|
||||
return (
|
||||
<DataLinksInlineEditor
|
||||
links={value}
|
||||
onChange={onChange}
|
||||
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
|
||||
data={[]}
|
||||
oneClickEnabled={settings?.oneClickLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,8 @@ export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<
|
||||
if (shouldAddBorderEditor) {
|
||||
optionBuilder.addBorder(builder, ctx);
|
||||
}
|
||||
|
||||
optionBuilder.addDataLinks(builder, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { FieldType } from '@grafana/data';
|
||||
import { FieldType, standardEditorsRegistry } 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';
|
||||
@@ -11,10 +11,12 @@ import { CanvasConnection, CanvasElementOptions } from '../panelcfg.gen';
|
||||
import { LineStyle } from '../types';
|
||||
|
||||
import { LineStyleEditor } from './LineStyleEditor';
|
||||
import { DataLinksEditor } from './element/DataLinksEditor';
|
||||
|
||||
interface OptionSuppliers {
|
||||
addBackground: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
addBorder: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
addDataLinks: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
addColor: PanelOptionsSupplier<CanvasConnection>;
|
||||
addSize: PanelOptionsSupplier<CanvasConnection>;
|
||||
addRadius: PanelOptionsSupplier<CanvasConnection>;
|
||||
@@ -205,4 +207,25 @@ export const optionBuilder: OptionSuppliers = {
|
||||
defaultValue: { value: LineStyle.Solid, label: 'Solid' },
|
||||
});
|
||||
},
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -111,6 +111,7 @@ export function getGlobalStyles(theme: GrafanaTheme2) {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '16px',
|
||||
height: 'auto',
|
||||
backgroundColor: 'transparent',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundAttachment: 'scroll',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { FieldConfigSource, PanelModel } from '@grafana/data';
|
||||
|
||||
import { canvasMigrationHandler } from './migrations';
|
||||
|
||||
describe('Canvas data links migration', () => {
|
||||
let prevFieldConfig: FieldConfigSource;
|
||||
|
||||
beforeEach(() => {
|
||||
prevFieldConfig = {
|
||||
defaults: {},
|
||||
overrides: [
|
||||
{
|
||||
matcher: { id: 'byName', options: 'B-series' },
|
||||
properties: [
|
||||
{
|
||||
id: 'links',
|
||||
value: [
|
||||
{ title: 'Test B-series override', url: '${__series.name}' },
|
||||
{ title: 'Test B-series override 2', url: '${__field.name}' },
|
||||
{ title: 'Test B-series override 3', url: '${__field.labels.foo}' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
it('should migrate data links', () => {
|
||||
const panel = {
|
||||
type: 'canvas',
|
||||
fieldConfig: prevFieldConfig,
|
||||
options: {
|
||||
root: {
|
||||
elements: [
|
||||
{
|
||||
type: 'metric-value',
|
||||
config: {
|
||||
text: {
|
||||
mode: 'field',
|
||||
field: 'B-series',
|
||||
fixed: '',
|
||||
},
|
||||
size: 20,
|
||||
color: {
|
||||
fixed: '#000000',
|
||||
},
|
||||
align: 'center',
|
||||
valign: 'middle',
|
||||
},
|
||||
background: {
|
||||
color: {
|
||||
field: 'time',
|
||||
fixed: '#D9D9D9',
|
||||
},
|
||||
},
|
||||
border: {
|
||||
color: {
|
||||
fixed: 'dark-green',
|
||||
},
|
||||
},
|
||||
placement: {
|
||||
top: 100,
|
||||
left: 100,
|
||||
width: 260,
|
||||
height: 50,
|
||||
},
|
||||
name: 'Element 1',
|
||||
constraint: {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
},
|
||||
links: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
pluginVersion: '11.1.0',
|
||||
} as unknown as PanelModel;
|
||||
panel.options = canvasMigrationHandler(panel);
|
||||
|
||||
const links = panel.options.root.elements[0].links;
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links[0].url).toBe('${__data.fields["B-series"]}');
|
||||
expect(links[1].url).toBe('${__data.fields["B-series"]}');
|
||||
expect(links[2].url).toBe('${__data.fields["B-series"].labels.foo}');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PanelModel } from '@grafana/data';
|
||||
import { DataLink, DynamicConfigValue, FieldMatcherID, PanelModel } from '@grafana/data';
|
||||
import { CanvasElementOptions } from 'app/features/canvas/element';
|
||||
|
||||
import { Options } from './panelcfg.gen';
|
||||
|
||||
@@ -42,5 +43,68 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
|
||||
}
|
||||
}
|
||||
|
||||
if (parseFloat(pluginVersion) < 11.2) {
|
||||
for (let idx = 0; idx < panel.fieldConfig.overrides.length; idx++) {
|
||||
const override = panel.fieldConfig.overrides[idx];
|
||||
|
||||
if (override.matcher.id === FieldMatcherID.byName) {
|
||||
let props: DynamicConfigValue[] = [];
|
||||
|
||||
// append override links to elements with dimensions mapped to same field name
|
||||
for (const prop of override.properties) {
|
||||
if (prop.id === 'links') {
|
||||
addLinks(panel.options.root.elements, prop.value, override.matcher.options);
|
||||
} else {
|
||||
props.push(prop);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.length > 0) {
|
||||
override.properties = props;
|
||||
} else {
|
||||
panel.fieldConfig.overrides.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return panel.options;
|
||||
};
|
||||
|
||||
function addLinks(elements: CanvasElementOptions[], links: DataLink[], fieldName?: string) {
|
||||
const varsNamesRegex = /(\${__field.name})|(\${__field.labels.*?})|(\${__series.name})/g;
|
||||
|
||||
const linksCopy = [...links];
|
||||
linksCopy.forEach((link) => {
|
||||
const isFieldOrSeries = varsNamesRegex.test(link.url);
|
||||
if (isFieldOrSeries) {
|
||||
link.url = link.url.replace(varsNamesRegex, (match, fieldName1, fieldLabels1, seriesName1) => {
|
||||
if (fieldName1 || seriesName1) {
|
||||
return '${__data.fields["' + fieldName + '"]}';
|
||||
}
|
||||
|
||||
if (fieldLabels1) {
|
||||
const labels = fieldLabels1.match(new RegExp('.labels' + '(.*)' + '}'));
|
||||
return '${__data.fields["' + fieldName + '"].labels' + labels[1] + '}';
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
elements.forEach((element) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let cfg: Record<string, any> = element.config;
|
||||
|
||||
for (let k in cfg) {
|
||||
let dim = cfg[k];
|
||||
|
||||
// todo: getFieldDisplayName?
|
||||
if (dim.field === fieldName) {
|
||||
element.links ??= [];
|
||||
element.links.push(...linksCopy);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ export const plugin = new PanelPlugin<Options>(CanvasPanel)
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
[FieldConfigProperty.Links]: {
|
||||
hideFromDefaults: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
.setMigrationHandler(canvasMigrationHandler)
|
||||
|
||||
@@ -87,6 +87,7 @@ composableKinds: PanelCfg: {
|
||||
background?: BackgroundConfig
|
||||
border?: LineConfig
|
||||
connections?: [...CanvasConnection]
|
||||
oneClickLinks?: bool
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
Options: {
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface CanvasElementOptions {
|
||||
connections?: Array<CanvasConnection>;
|
||||
constraint?: Constraint;
|
||||
name: string;
|
||||
oneClickLinks?: boolean;
|
||||
placement?: Placement;
|
||||
type: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isNumber, isString } from 'lodash';
|
||||
|
||||
import { AppEvents, Field, getFieldDisplayName, LinkModel, PluginState, SelectableValue } from '@grafana/data';
|
||||
import { AppEvents, PluginState, SelectableValue } from '@grafana/data';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { hasAlphaPanels, config } from 'app/core/config';
|
||||
import {
|
||||
@@ -14,7 +14,6 @@ import { advancedElementItems, canvasElementRegistry, defaultElementItems } from
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { FrameState } from 'app/features/canvas/runtime/frame';
|
||||
import { Scene, SelectionParams } from 'app/features/canvas/runtime/scene';
|
||||
import { DimensionContext } from 'app/features/dimensions';
|
||||
|
||||
import { AnchorPoint, ConnectionState, LineStyle, StrokeDasharray } from './types';
|
||||
|
||||
@@ -105,169 +104,6 @@ export function onAddItem(sel: SelectableValue<string>, rootLayer: FrameState |
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Provided a given field add any matching data links
|
||||
* Mutates the links object in place which is then returned by the `getDataLinks` function downstream
|
||||
*/
|
||||
const addDataLinkForField = (
|
||||
field: Field<unknown>,
|
||||
data: string | undefined,
|
||||
linkLookup: Set<string>,
|
||||
links: Array<LinkModel<Field>>
|
||||
): void => {
|
||||
if (field?.getLinks) {
|
||||
const disp = field.display ? field.display(data) : { text: `${data}`, numeric: +data! };
|
||||
// TODO add more control over which row index each element uses
|
||||
const valueRowIndex = field.values.length - 1;
|
||||
field.getLinks({ calculatedValue: disp, valueRowIndex }).forEach((link) => {
|
||||
const key = `${link.title}/${link.href}`;
|
||||
if (!linkLookup.has(key)) {
|
||||
links.push(link);
|
||||
linkLookup.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: This could be refactored a fair amount, ideally the element specific config code should be owned by each element and not in this shared util file
|
||||
export function getDataLinks(
|
||||
dimensionContext: DimensionContext,
|
||||
elementOptions: CanvasElementOptions,
|
||||
data: string | undefined
|
||||
): LinkModel[] {
|
||||
const panelData = dimensionContext.getPanelData();
|
||||
const frames = panelData?.series;
|
||||
|
||||
const links: Array<LinkModel<Field>> = [];
|
||||
const linkLookup = new Set<string>();
|
||||
|
||||
const elementConfig = elementOptions.config;
|
||||
|
||||
frames?.forEach((frame) => {
|
||||
const visibleFields = frame.fields.filter((field) => !Boolean(field.config.custom?.hideFrom?.tooltip));
|
||||
|
||||
// Text config
|
||||
const isTextTiedToFieldData =
|
||||
elementConfig?.text?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig?.text?.field);
|
||||
const isTextColorTiedToFieldData =
|
||||
elementConfig?.color?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig?.color?.field);
|
||||
|
||||
// General element config
|
||||
const isElementBackgroundColorTiedToFieldData =
|
||||
elementOptions?.background?.color?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementOptions?.background?.color?.field);
|
||||
const isElementBackgroundImageTiedToFieldData =
|
||||
elementOptions?.background?.image?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementOptions?.background?.image?.field);
|
||||
const isElementBorderColorTiedToFieldData =
|
||||
elementOptions?.border?.color?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementOptions?.border?.color?.field);
|
||||
|
||||
// Icon config
|
||||
const isIconSVGTiedToFieldData =
|
||||
elementConfig?.path?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig?.path?.field);
|
||||
const isIconColorTiedToFieldData =
|
||||
elementConfig?.fill?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig?.fill?.field);
|
||||
|
||||
// Wind turbine config (maybe remove / not support this?)
|
||||
const isWindTurbineRPMTiedToFieldData =
|
||||
elementConfig?.rpm?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig?.rpm?.field);
|
||||
|
||||
// Server config
|
||||
const isServerBlinkRateTiedToFieldData =
|
||||
elementConfig?.blinkRate?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig?.blinkRate?.field);
|
||||
const isServerStatusColorTiedToFieldData =
|
||||
elementConfig?.statusColor?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig?.statusColor?.field);
|
||||
const isServerBulbColorTiedToFieldData =
|
||||
elementConfig?.bulbColor?.field &&
|
||||
visibleFields.some((field) => getFieldDisplayName(field, frame) === elementConfig?.bulbColor?.field);
|
||||
|
||||
if (isTextTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementConfig?.text?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isTextColorTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementConfig?.color?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isElementBackgroundColorTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementOptions?.background?.color?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isElementBackgroundImageTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementOptions?.background?.image?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isElementBorderColorTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementOptions?.border?.color?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isIconSVGTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementConfig?.path?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isIconColorTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementConfig?.fill?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isWindTurbineRPMTiedToFieldData) {
|
||||
const field = visibleFields.filter((field) => getFieldDisplayName(field, frame) === elementConfig?.rpm?.field)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isServerBlinkRateTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementConfig?.blinkRate?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isServerStatusColorTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementConfig?.statusColor?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
|
||||
if (isServerBulbColorTiedToFieldData) {
|
||||
const field = visibleFields.filter(
|
||||
(field) => getFieldDisplayName(field, frame) === elementConfig?.bulbColor?.field
|
||||
)[0];
|
||||
addDataLinkForField(field, data, linkLookup, links);
|
||||
}
|
||||
});
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function isConnectionSource(element: ElementState) {
|
||||
return element.options.connections && element.options.connections.length > 0;
|
||||
}
|
||||
@@ -398,8 +234,8 @@ export const calculateDistance = (x1: number, y1: number, x2: number, y2: number
|
||||
// @TODO revisit, currently returning last row index for field
|
||||
export const getRowIndex = (fieldName: string | undefined, scene: Scene) => {
|
||||
if (fieldName) {
|
||||
const series = scene.context.getPanelData()?.series[0];
|
||||
const field = series?.fields.find((f) => (f.name = fieldName));
|
||||
const series = scene.data?.series[0];
|
||||
const field = series?.fields.find((field) => field.name === fieldName);
|
||||
const data = field?.values;
|
||||
return data ? data.length - 1 : 0;
|
||||
}
|
||||
|
||||
@@ -3685,6 +3685,7 @@ __metadata:
|
||||
"@grafana/faro-web-sdk": "npm:^1.3.6"
|
||||
"@grafana/schema": "npm:11.2.0-pre"
|
||||
"@grafana/tsconfig": "npm:^1.3.0-rc1"
|
||||
"@hello-pangea/dnd": "npm:16.6.0"
|
||||
"@leeoniya/ufuzzy": "npm:1.0.14"
|
||||
"@monaco-editor/react": "npm:4.6.0"
|
||||
"@popperjs/core": "npm:2.11.8"
|
||||
|
||||
Reference in New Issue
Block a user