feat: support anchored annotation tooltips on click

This commit is contained in:
Galen
2025-10-07 15:37:46 -05:00
parent 9d60d03d11
commit 12249d3211
5 changed files with 99 additions and 26 deletions
@@ -67,7 +67,7 @@ export const AnnotationsPlugin2 = ({
const [plot, setPlot] = useState<uPlot>(); const [plot, setPlot] = useState<uPlot>();
const [portalRoot] = useState(() => getPortalContainer()); const [portalRoot] = useState(() => getPortalContainer());
const [annoIdx, setAnnoIdx] = useState<string | undefined>();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const getColorByName = useTheme2().visualization.getColorByName; const getColorByName = useTheme2().visualization.getColorByName;
@@ -209,6 +209,11 @@ export const AnnotationsPlugin2 = ({
} }
}, [annos, plot]); }, [annos, plot]);
// Set active annotation tooltip state
const setAnnotationIndex = useCallback((annoIdx: string | undefined) => {
setAnnoIdx(annoIdx);
}, []);
if (plot) { if (plot) {
let markers = annos.flatMap((frame, frameIdx) => { let markers = annos.flatMap((frame, frameIdx) => {
let vals = getVals(frame); let vals = getVals(frame);
@@ -245,10 +250,20 @@ export const AnnotationsPlugin2 = ({
// @TODO: Reset newRange after annotation is saved // @TODO: Reset newRange after annotation is saved
if (isVisible) { if (isVisible) {
let isWip = frame.meta?.custom?.isWip; const isWip = frame.meta?.custom?.isWip;
const setAnnotation = (active: boolean) => {
if (active) {
setAnnotationIndex(`${frameIdx}:${i}`);
} else {
setAnnotationIndex(undefined);
}
};
markers.push( markers.push(
<AnnotationMarker2 <AnnotationMarker2
pinAnnotation={setAnnotation}
isPinned={annoIdx === `${frameIdx}:${i}`}
showOnHover={!annoIdx}
annoIdx={i} annoIdx={i}
annoVals={vals} annoVals={vals}
className={className} className={className}
@@ -276,6 +291,7 @@ const getStyles = () => ({
position: 'absolute', position: 'absolute',
width: 0, width: 0,
height: 0, height: 0,
border: 'none',
borderLeft: '5px solid transparent', borderLeft: '5px solid transparent',
borderRight: '5px solid transparent', borderRight: '5px solid transparent',
borderBottomWidth: '5px', borderBottomWidth: '5px',
@@ -283,11 +299,16 @@ const getStyles = () => ({
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
cursor: 'pointer', cursor: 'pointer',
zIndex: 1, zIndex: 1,
padding: 0,
background: 'none',
}), }),
annoRegion: css({ annoRegion: css({
border: 'none',
position: 'absolute', position: 'absolute',
height: '5px', height: '5px',
cursor: 'pointer', cursor: 'pointer',
zIndex: 1, zIndex: 1,
padding: 0,
background: 'none',
}), }),
}); });
@@ -5,7 +5,7 @@ import { useAsyncFn, useClickAway } from 'react-use';
import { AnnotationEventUIModel, GrafanaTheme2, dateTimeFormat, systemDateFormats } from '@grafana/data'; import { AnnotationEventUIModel, GrafanaTheme2, dateTimeFormat, systemDateFormats } from '@grafana/data';
import { Trans, t } from '@grafana/i18n'; import { Trans, t } from '@grafana/i18n';
import { Button, Field, Stack, TextArea, usePanelContext, useStyles2 } from '@grafana/ui'; import { Button, Field, IconButton, Stack, TextArea, usePanelContext, useStyles2 } from '@grafana/ui';
import { Form } from 'app/core/components/Form/Form'; import { Form } from 'app/core/components/Form/Form';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { annotationServer } from 'app/features/annotations/api'; import { annotationServer } from 'app/features/annotations/api';
@@ -15,6 +15,7 @@ interface Props {
annoIdx: number; annoIdx: number;
timeZone: string; timeZone: string;
dismiss: () => void; dismiss: () => void;
isPinned: boolean;
} }
interface AnnotationEditFormDTO { interface AnnotationEditFormDTO {
@@ -22,7 +23,7 @@ interface AnnotationEditFormDTO {
tags: string[]; tags: string[];
} }
export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeZone, ...otherProps }: Props) => { export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeZone, isPinned, ...otherProps }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { onAnnotationCreate, onAnnotationUpdate } = usePanelContext(); const { onAnnotationCreate, onAnnotationUpdate } = usePanelContext();
@@ -77,6 +78,18 @@ export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeZone, ...oth
: t('timeseries.annotation-editor2.add-annotation', 'Add annotation')} : t('timeseries.annotation-editor2.add-annotation', 'Add annotation')}
</div> </div>
<div>{time}</div> <div>{time}</div>
{isPinned && (
<IconButton
name={'times'}
size={'sm'}
onClick={(e) => {
// Don't trigger onClick
e.stopPropagation();
dismiss();
}}
tooltip={t('timeseries.annotation-editor2.tooltip-close', 'Close')}
/>
)}
</Stack> </Stack>
</div> </div>
<Form<AnnotationEditFormDTO> <Form<AnnotationEditFormDTO>
@@ -88,6 +101,7 @@ export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeZone, ...oth
<> <>
<div className={styles.content}> <div className={styles.content}>
<Field <Field
autoFocus={true}
label={t('timeseries.annotation-editor2.label-description', 'Description')} label={t('timeseries.annotation-editor2.label-description', 'Description')}
invalid={!!errors.description} invalid={!!errors.description}
error={errors?.description?.message} error={errors?.description?.message}
@@ -8,7 +8,7 @@ import { createPortal } from 'react-dom';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { TimeZone } from '@grafana/schema'; import { TimeZone } from '@grafana/schema';
import { floatingUtils, useStyles2 } from '@grafana/ui'; import { ClickOutsideWrapper, floatingUtils, useStyles2 } from '@grafana/ui';
import { AnnotationEditor2 } from './AnnotationEditor2'; import { AnnotationEditor2 } from './AnnotationEditor2';
import { AnnotationTooltip2 } from './AnnotationTooltip2'; import { AnnotationTooltip2 } from './AnnotationTooltip2';
@@ -21,12 +21,11 @@ interface AnnoBoxProps {
timeZone: TimeZone; timeZone: TimeZone;
exitWipEdit?: null | (() => void); exitWipEdit?: null | (() => void);
portalRoot: HTMLElement; portalRoot: HTMLElement;
pinAnnotation: (pin: boolean) => void;
isPinned: boolean;
showOnHover: boolean;
} }
const STATE_DEFAULT = 0;
const STATE_EDITING = 1;
const STATE_HOVERED = 2;
export const AnnotationMarker2 = ({ export const AnnotationMarker2 = ({
annoVals, annoVals,
annoIdx, annoIdx,
@@ -35,11 +34,15 @@ export const AnnotationMarker2 = ({
exitWipEdit, exitWipEdit,
timeZone, timeZone,
portalRoot, portalRoot,
pinAnnotation,
showOnHover,
isPinned,
}: AnnoBoxProps) => { }: AnnoBoxProps) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const placement = 'bottom'; const placement = 'bottom';
const [state, setState] = useState(exitWipEdit != null ? STATE_EDITING : STATE_DEFAULT); const [editing, setEditing] = useState(exitWipEdit != null);
const [isHovering, setIsHovering] = useState(false);
const { refs, floatingStyles } = useFloating({ const { refs, floatingStyles } = useFloating({
open: true, open: true,
placement, placement,
@@ -48,43 +51,57 @@ export const AnnotationMarker2 = ({
strategy: 'fixed', strategy: 'fixed',
}); });
const onClose = () => {
pinAnnotation(false);
setIsHovering(false);
};
const contents = const contents =
state === STATE_HOVERED ? ( (isPinned && !editing) || (showOnHover && isHovering && !editing) ? (
<AnnotationTooltip2 <AnnotationTooltip2
annoIdx={annoIdx} annoIdx={annoIdx}
annoVals={annoVals} annoVals={annoVals}
timeZone={timeZone} timeZone={timeZone}
onEdit={() => setState(STATE_EDITING)} onClose={onClose}
isPinned={isPinned}
onEdit={() => setEditing(true)}
/> />
) : state === STATE_EDITING ? ( ) : editing ? (
<AnnotationEditor2 <AnnotationEditor2
isPinned={isPinned}
annoIdx={annoIdx} annoIdx={annoIdx}
annoVals={annoVals} annoVals={annoVals}
timeZone={timeZone} timeZone={timeZone}
dismiss={() => { dismiss={() => {
exitWipEdit?.(); exitWipEdit?.();
setState(STATE_DEFAULT); setEditing(false);
onClose();
}} }}
/> />
) : null; ) : null;
return ( return (
<div <button
ref={refs.setReference} ref={refs.setReference}
className={className} className={className}
style={style!} style={style!}
onMouseEnter={() => state !== STATE_EDITING && setState(STATE_HOVERED)} onFocus={() => setIsHovering(true)}
onMouseLeave={() => state !== STATE_EDITING && setState(STATE_DEFAULT)} onBlur={() => setIsHovering(false)}
onClick={() => pinAnnotation(true)}
onMouseEnter={() => showOnHover && setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
data-testid={selectors.pages.Dashboard.Annotations.marker} data-testid={selectors.pages.Dashboard.Annotations.marker}
> >
{contents && {contents &&
createPortal( createPortal(
<div ref={refs.setFloating} className={styles.annoBox} style={floatingStyles} data-testid="annotation-marker"> <div ref={refs.setFloating} className={styles.annoBox} style={floatingStyles} data-testid="annotation-marker">
{contents} <ClickOutsideWrapper includeButtonPress={false} useCapture={true} onClick={() => pinAnnotation(false)}>
{contents}
</ClickOutsideWrapper>
</div>, </div>,
portalRoot portalRoot
)} )}
</div> </button>
); );
}; };
@@ -10,24 +10,31 @@ interface Props {
annoVals: Record<string, any[]>; annoVals: Record<string, any[]>;
annoIdx: number; annoIdx: number;
timeZone: string; timeZone: string;
isPinned: boolean;
onClose: () => void;
onEdit: () => void; onEdit: () => void;
} }
const retFalse = () => false; const retFalse = () => false;
export const AnnotationTooltip2 = ({ annoVals, annoIdx, timeZone, onEdit }: Props) => { export const AnnotationTooltip2 = ({ annoVals, annoIdx, timeZone, isPinned, onClose, onEdit }: Props) => {
const annoId = annoVals.id?.[annoIdx]; const annoId = annoVals.id?.[annoIdx];
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const focusRef = React.useRef<HTMLButtonElement | null>(null);
const { canEditAnnotations = retFalse, canDeleteAnnotations = retFalse, onAnnotationDelete } = usePanelContext(); const { canEditAnnotations = retFalse, canDeleteAnnotations = retFalse, onAnnotationDelete } = usePanelContext();
const dashboardUID = annoVals.dashboardUID?.[annoIdx]; const dashboardUID = annoVals.dashboardUID?.[annoIdx];
// grafana can be configured to load alert rules from loki. Those annotations cannot be edited or deleted. The id being 0 is the best indicator the annotation came from loki // grafana can be configured to load alert rules from loki. Those annotations cannot be edited or deleted. The id being 0 is the best indicator the annotation came from loki
const canEdit = annoId !== 0 && canEditAnnotations(dashboardUID); const canEdit = annoId !== 0 && canEditAnnotations(dashboardUID);
const canDelete = annoId !== 0 && canDeleteAnnotations(dashboardUID) && onAnnotationDelete != null; const canDelete = annoId !== 0 && canDeleteAnnotations(dashboardUID) && onAnnotationDelete != null;
React.useEffect(() => {
if (isPinned) {
focusRef.current?.focus();
}
}, [isPinned]);
const timeFormatter = (value: number) => const timeFormatter = (value: number) =>
dateTimeFormat(value, { dateTimeFormat(value, {
format: systemDateFormats.fullDate, format: systemDateFormats.fullDate,
@@ -73,8 +80,20 @@ export const AnnotationTooltip2 = ({ annoVals, annoIdx, timeZone, onEdit }: Prop
</span> </span>
{time} {time}
</div> </div>
{(canEdit || canDelete) && ( {(canEdit || canDelete || isPinned) && (
<div className={styles.editControls}> <div className={styles.controls}>
{isPinned && (
<IconButton
name={'times'}
size={'sm'}
onClick={(e) => {
// Don't trigger onClick
e.stopPropagation();
onClose();
}}
tooltip={t('timeseries.annotation-tooltip2.tooltip-close', 'Close')}
/>
)}
{canEdit && ( {canEdit && (
<IconButton <IconButton
name={'pen'} name={'pen'}
@@ -134,7 +153,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: theme.colors.text.primary, color: theme.colors.text.primary,
fontWeight: 400, fontWeight: 400,
}), }),
editControls: css({ controls: css({
display: 'flex', display: 'flex',
'> :last-child': { '> :last-child': {
marginLeft: 0, marginLeft: 0,
+3 -1
View File
@@ -13044,9 +13044,11 @@
"label-tags": "Tags", "label-tags": "Tags",
"placeholder-add-tags": "Add tags", "placeholder-add-tags": "Add tags",
"save": "Save", "save": "Save",
"saving": "Saving" "saving": "Saving",
"tooltip-close": "Close"
}, },
"annotation-tooltip2": { "annotation-tooltip2": {
"tooltip-close": "Close",
"tooltip-delete": "Delete", "tooltip-delete": "Delete",
"tooltip-edit": "Edit" "tooltip-edit": "Edit"
}, },