feat: support anchored annotation tooltips on click
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user