DataLinks: Add one-click option (#98563)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
co-authored by
Leon Sorokin
parent
e9d9b15295
commit
572be19f76
@@ -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">
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user