DataLinks: Add one-click option (#98563)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Adela Almasan
2025-01-23 12:47:42 -06:00
committed by GitHub
co-authored by Leon Sorokin
parent e9d9b15295
commit 572be19f76
12 changed files with 139 additions and 30 deletions
@@ -501,6 +501,7 @@ export const getLinksSupplier =
});
},
origin: field,
oneClick: link.oneClick ?? false,
};
} else {
linkModel = {
@@ -508,6 +509,7 @@ export const getLinksSupplier =
title: replaceVariables(link.title || '', dataLinkScopedVars),
target: link.targetBlank ? '_blank' : undefined,
origin: field,
oneClick: link.oneClick ?? false,
};
}
@@ -55,6 +55,8 @@ export interface DataLink<T extends DataQuery = any> {
correlationData?: ExploreCorrelationHelperData;
transformations?: DataLinkTransformationConfig[];
};
oneClick?: boolean;
}
/**
@@ -98,6 +100,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;
oneClick?: boolean;
}
/**
@@ -5,7 +5,7 @@ import { VariableSuggestion, GrafanaTheme2, DataLink } from '@grafana/data';
import { useStyles2 } from '../../themes/index';
import { isCompactUrl } from '../../utils/dataLinks';
import { Trans } from '../../utils/i18n';
import { t, Trans } from '../../utils/i18n';
import { Field } from '../Forms/Field';
import { Input } from '../Input/Input';
import { Switch } from '../Switch/Switch';
@@ -45,6 +45,10 @@ export const DataLinkEditor = memo(({ index, value, onChange, suggestions, isLas
onChange(index, { ...value, targetBlank: !value.targetBlank });
};
const onOneClickChanged = () => {
onChange(index, { ...value, oneClick: !value.oneClick });
};
return (
<div className={styles.listItem}>
<Field label="Title">
@@ -63,6 +67,16 @@ export const DataLinkEditor = memo(({ index, value, onChange, suggestions, isLas
<Switch value={value.targetBlank || false} onChange={onOpenInNewTabChanged} />
</Field>
<Field
label={t('grafana-ui.data-link-inline-editor.one-click', 'One click')}
description={t(
'grafana-ui.data-link-editor-modal.one-click-description',
'Only one link can have one click enabled at a time'
)}
>
<Switch value={value.oneClick || false} onChange={onOneClickChanged} />
</Field>
{isLast && (
<div className={styles.infoText}>
<Trans i18nKey="grafana-ui.data-link-editor.info">
@@ -51,6 +51,15 @@ export const DataLinksInlineEditor = ({
setIsNew(false);
}
}
if (link.oneClick === true) {
linksSafe.forEach((link) => {
if (link.oneClick) {
link.oneClick = false;
}
});
}
const update = cloneDeep(linksSafe);
update[index] = link;
onChange(update);
@@ -5,6 +5,8 @@ import { DataFrame, DataLink, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { isCompactUrl } from '../../../utils';
import { t } from '../../../utils/i18n';
import { Badge } from '../../Badge/Badge';
import { Icon } from '../../Icon/Icon';
import { IconButton } from '../../IconButton/IconButton';
import { Tooltip } from '../../Tooltip/Tooltip';
@@ -22,7 +24,7 @@ export interface DataLinksListItemProps {
export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: DataLinksListItemProps) => {
const styles = useStyles2(getDataLinkListItemStyles);
const { title = '', url = '' } = link;
const { title = '', url = '', oneClick = false } = link;
const hasTitle = title.trim() !== '';
const hasUrl = url.trim() !== '';
@@ -52,6 +54,13 @@ export const DataLinksListItem = ({ link, onEdit, onRemove, index, itemKey }: Da
</Tooltip>
</div>
<div className={styles.icons}>
{oneClick && (
<Badge
color="blue"
text={t('grafana-ui.data-links-inline-editor.one-click', 'One click')}
tooltip={t('grafana-ui.data-links-inline-editor.one-click-enabled', 'One click enabled')}
/>
)}
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit data link" />
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove data link" />
<div className={styles.dragIcon} {...provided.dragHandleProps}>
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { ActionModel, Field, GrafanaTheme2, LinkModel } from '@grafana/data';
import { Button, DataLinkButton, Stack } from '..';
import { Button, DataLinkButton, Icon, Stack } from '..';
import { useStyles2 } from '../../themes';
import { Trans } from '../../utils/i18n';
import { ActionButton } from '../Actions/ActionButton';
@@ -16,6 +16,21 @@ interface VizTooltipFooterProps {
export const ADD_ANNOTATION_ID = 'add-annotation-button';
const renderDataLinks = (dataLinks: LinkModel[], styles: ReturnType<typeof getStyles>) => {
const oneClickLink = dataLinks.find((link) => link.oneClick === true);
if (oneClickLink != null) {
return (
<Stack direction="column" justifyContent="flex-start" gap={0.5}>
<span className={styles.oneClickWrapper}>
<Icon name="info-circle" size="lg" className={styles.infoIcon} />
<Trans i18nKey="grafana-ui.viz-tooltip.footer-click-to-navigate">
Click to open {{ linkTitle: oneClickLink.title }}
</Trans>
</span>
</Stack>
);
}
return (
<Stack direction="column" justifyContent="flex-start" gap={0.5}>
{dataLinks.map((link, i) => (
@@ -35,14 +50,15 @@ const renderActions = (actions: ActionModel[]) => {
);
};
export const VizTooltipFooter = ({ dataLinks, actions, annotate }: VizTooltipFooterProps) => {
export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizTooltipFooterProps) => {
const styles = useStyles2(getStyles);
const hasOneClickLink = dataLinks.some((link) => link.oneClick === true);
return (
<div className={styles.wrapper}>
{dataLinks?.length > 0 && <div className={styles.dataLinks}>{renderDataLinks(dataLinks, styles)}</div>}
{actions && actions.length > 0 && <div className={styles.dataLinks}>{renderActions(actions)}</div>}
{annotate != null && (
{dataLinks.length > 0 && <div className={styles.dataLinks}>{renderDataLinks(dataLinks, styles)}</div>}
{!hasOneClickLink && actions.length > 0 && <div className={styles.dataLinks}>{renderActions(actions)}</div>}
{!hasOneClickLink && annotate != null && (
<div className={styles.addAnnotations}>
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
<Trans i18nKey="grafana-ui.viz-tooltip.footer-add-annotation">Add annotation</Trans>
@@ -75,4 +91,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
background: 'none',
},
}),
oneClickWrapper: css({
display: 'flex',
alignItems: 'center',
}),
infoIcon: css({
color: theme.colors.primary.main,
paddingRight: theme.spacing(0.5),
}),
});
@@ -27,7 +27,7 @@ export const enum TooltipHoverMode {
xyOne,
}
type GeDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[];
type GetDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[];
interface TooltipPlugin2Props {
config: UPlotConfigBuilder;
@@ -42,7 +42,7 @@ interface TooltipPlugin2Props {
clientZoom?: boolean;
onSelectRange?: OnSelectRangeCallback;
getDataLinks?: GeDataLinksCallback;
getDataLinks?: GetDataLinksCallback;
render: (
u: uPlot,
@@ -106,7 +106,7 @@ const MIN_ZOOM_DIST = 5;
const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey;
const getDataLinksFallback: GeDataLinksCallback = () => [];
const getDataLinksFallback: GetDataLinksCallback = () => [];
/**
* @alpha
@@ -194,11 +194,16 @@ export const TooltipPlugin2 = ({
let offsetY = 0;
let selectedRange: TimeRange2 | null = null;
let seriesIdxs: Array<number | null> = plot?.cursor.idxs!.slice()!;
let seriesIdxs: Array<number | null> = [];
let closestSeriesIdx: number | null = null;
let viaSync = false;
let dataLinks: LinkModel[] = [];
// for onceClick link rendering during mousemoves we use these pre-generated first links or actions
// these will be wrong if the titles have interpolation using the hovered *value*
// but this should be quite rare. we'll fix it if someone actually encounters this
let persistentLinks: LinkModel[][] = [];
let pendingRender = false;
let pendingPinned = false;
@@ -261,7 +266,7 @@ export const TooltipPlugin2 = ({
dismiss,
selectedRange,
viaSync,
dataLinks
_isPinned ? dataLinks : closestSeriesIdx != null ? persistentLinks[closestSeriesIdx] : []
)
: null,
dismiss,
@@ -269,6 +274,9 @@ export const TooltipPlugin2 = ({
setState(state);
// TODO: set u.over.style.cursor = 'pointer' if we hovered a oneClick point
// else revert to default...but only when the new pointer is different from prev
selectedRange = null;
};
@@ -313,7 +321,7 @@ export const TooltipPlugin2 = ({
);
}
// this handles pinning
// this handles pinning, 0-width range selection, and one-click
u.over.addEventListener('click', (e) => {
if (e.target === u.over) {
if (e.ctrlKey || e.metaKey) {
@@ -333,14 +341,19 @@ export const TooltipPlugin2 = ({
scheduleRender(false);
}
// only pinnable tooltip is visible *and* is within proximity to series/point
else if (_isHovering && closestSeriesIdx != null && !_isPinned) {
dataLinks = getLinksRef.current(closestSeriesIdx!, seriesIdxs[closestSeriesIdx!]!);
// if tooltip visible, not pinned, and within proximity to a series/point
else if (_isHovering && !_isPinned && closestSeriesIdx != null) {
dataLinks = getLinksRef.current(closestSeriesIdx, seriesIdxs[closestSeriesIdx]!);
const oneClickLink = dataLinks.find((dataLink) => dataLink.oneClick === true);
setTimeout(() => {
_isPinned = true;
scheduleRender(true);
}, 0);
if (oneClickLink != null) {
window.open(oneClickLink.href, oneClickLink.target ?? '_self');
} else {
setTimeout(() => {
_isPinned = true;
scheduleRender(true);
}, 0);
}
}
}
});
@@ -511,6 +524,21 @@ export const TooltipPlugin2 = ({
seriesIdxs = _plot?.cursor!.idxs!.slice()!;
_someSeriesIdx = seriesIdxs.some((v, i) => i > 0 && v != null);
if (persistentLinks.length === 0) {
persistentLinks = seriesIdxs.map((v, seriesIdx) => {
if (seriesIdx > 0) {
const links = getDataLinks(seriesIdx, seriesIdxs[seriesIdx]!);
const oneClickLink = links.find((dataLink) => dataLink.oneClick === true);
if (oneClickLink) {
return [oneClickLink];
}
}
return [];
});
}
viaSync = u.cursor.event == null;
let prevIsHovering = _isHovering;
updateHovering();
@@ -97,7 +97,7 @@ export const ActionsInlineEditor = ({
{showOneClick && actionsSafe.length > 0 && (
<div className={styles.oneClickOverlay}>
<span className={styles.oneClickSpan}>
<Trans i18nKey="actions-editor.inline.one-click-link">One-click link</Trans>
<Trans i18nKey="actions-editor.inline.one-click-action">One-click action</Trans>
</span>
</div>
)}
@@ -24,6 +24,8 @@ export const getDataLinks = (field: Field, rowIdx: number) => {
return links;
};
export const getAllFrameActions = (dataFrame: DataFrame) => {};
export const getFieldActions = (
dataFrame: DataFrame,
field: Field,
@@ -51,7 +51,7 @@ export const TimeSeriesTooltip = ({
isPinned,
annotate,
maxHeight,
replaceVariables,
replaceVariables = (str) => str,
dataLinks,
hideZeros,
}: TimeSeriesTooltipProps) => {
@@ -80,12 +80,16 @@ export const TimeSeriesTooltip = ({
let footer: ReactNode;
if (isPinned && seriesIdx != null) {
if (seriesIdx != null) {
const field = series.fields[seriesIdx];
const dataIdx = dataIdxs[seriesIdx]!;
const actions = getFieldActions(series, field, replaceVariables!, dataIdx);
const hasOneClickLink = dataLinks.some((dataLink) => dataLink.oneClick === true);
footer = <VizTooltipFooter dataLinks={dataLinks} actions={actions} annotate={annotate} />;
if (isPinned || hasOneClickLink) {
const dataIdx = dataIdxs[seriesIdx]!;
const actions = getFieldActions(series, field, replaceVariables, dataIdx);
footer = <VizTooltipFooter dataLinks={dataLinks} actions={actions} annotate={annotate} />;
}
}
const headerItem: VizTooltipItem | null = xField.config.custom?.hideFrom?.tooltip
+9 -2
View File
@@ -37,7 +37,7 @@
"actions-editor": {
"inline": {
"add-button": "Add action",
"one-click-link": "One-click link"
"one-click-action": "One-click action"
}
},
"admin": {
@@ -1391,10 +1391,16 @@
},
"data-link-editor-modal": {
"cancel": "Cancel",
"one-click-description": "Only one link can have one click enabled at a time",
"save": "Save"
},
"data-link-inline-editor": {
"one-click": "One click"
},
"data-links-inline-editor": {
"add-link": "Add link",
"one-click": "One click",
"one-click-enabled": "One click enabled",
"one-click-link": "One-click link"
},
"data-source-http-settings": {
@@ -1476,7 +1482,8 @@
"right-axis-indicator": "(right y-axis)"
},
"viz-tooltip": {
"footer-add-annotation": "Add annotation"
"footer-add-annotation": "Add annotation",
"footer-click-to-navigate": "Click to open {{linkTitle}}"
}
},
"graph": {
+9 -2
View File
@@ -37,7 +37,7 @@
"actions-editor": {
"inline": {
"add-button": "Åđđ äčŧįőʼn",
"one-click-link": "Øʼnę-čľįčĸ ľįʼnĸ"
"one-click-action": "Øʼnę-čľįčĸ äčŧįőʼn"
}
},
"admin": {
@@ -1391,10 +1391,16 @@
},
"data-link-editor-modal": {
"cancel": "Cäʼnčęľ",
"one-click-description": "Øʼnľy őʼnę ľįʼnĸ čäʼn ĥävę őʼnę čľįčĸ ęʼnäþľęđ äŧ ä ŧįmę",
"save": "Ŝävę"
},
"data-link-inline-editor": {
"one-click": "Øʼnę čľįčĸ"
},
"data-links-inline-editor": {
"add-link": "Åđđ ľįʼnĸ",
"one-click": "Øʼnę čľįčĸ",
"one-click-enabled": "Øʼnę čľįčĸ ęʼnäþľęđ",
"one-click-link": "Øʼnę-čľįčĸ ľįʼnĸ"
},
"data-source-http-settings": {
@@ -1476,7 +1482,8 @@
"right-axis-indicator": "(řįģĥŧ y-äχįş)"
},
"viz-tooltip": {
"footer-add-annotation": "Åđđ äʼnʼnőŧäŧįőʼn"
"footer-add-annotation": "Åđđ äʼnʼnőŧäŧįőʼn",
"footer-click-to-navigate": "Cľįčĸ ŧő őpęʼn {{linkTitle}}"
}
},
"graph": {