Canvas: Element level data links (#89079)

This commit is contained in:
Adela Almasan
2024-07-12 12:02:49 -05:00
committed by GitHub
parent d781ec2daa
commit 9bc68562d4
35 changed files with 544 additions and 280 deletions
+2 -1
View File
@@ -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;
}
/**
@@ -104,6 +104,7 @@ export interface CanvasElementOptions {
connections?: Array<CanvasConnection>;
constraint?: Constraint;
name: string;
oneClickLinks?: boolean;
placement?: Placement;
type: string;
}
+1
View File
@@ -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",
@@ -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',
}),
});
@@ -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 };
}
@@ -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',
},
}),
};
};
+3 -1
View File
@@ -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;
+1 -3
View File
@@ -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;
},
+1 -3
View File
@@ -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;
},
+68 -5
View File
@@ -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) => {
+3 -1
View File
@@ -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}');
});
});
+65 -1
View File
@@ -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;
}
+3 -167
View File
@@ -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;
}
+1
View File
@@ -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"