VizTooltips: Use global portal (#81986)

Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
pull/83353/head
Leon Sorokin 2 years ago committed by GitHub
parent e4276a4ede
commit 6e6b9a62a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 229
      packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx
  2. 5
      packages/grafana-ui/src/options/builder/tooltip.tsx
  3. 30
      public/app/core/components/TimelineChart/timeline.ts
  4. 25
      public/app/plugins/panel/candlestick/CandlestickPanel.tsx
  5. 17
      public/app/plugins/panel/heatmap/HeatmapPanel.tsx
  6. 5
      public/app/plugins/panel/heatmap/module.tsx
  7. 21
      public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx
  8. 21
      public/app/plugins/panel/status-history/StatusHistoryPanel.tsx
  9. 21
      public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
  10. 17
      public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx
  11. 28
      public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx
  12. 159
      public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx
  13. 38
      public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx

@ -1,12 +1,12 @@
import { css, cx } from '@emotion/css';
import React, { useLayoutEffect, useRef, useReducer, CSSProperties, useContext, useEffect } from 'react';
import React, { useLayoutEffect, useRef, useReducer, CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import uPlot from 'uplot';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { LayoutItemContext } from '../../Layout/LayoutItemContext';
import { getPortalContainer } from '../../Portal/Portal';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { CloseButton } from './CloseButton';
@ -29,6 +29,8 @@ interface TooltipPlugin2Props {
config: UPlotConfigBuilder;
hoverMode: TooltipHoverMode;
syncTooltip?: () => boolean;
// x only
queryZoom?: (range: { from: number; to: number }) => void;
// y-only, via shiftKey
@ -80,14 +82,16 @@ function mergeState(prevState: TooltipContainerState, nextState: Partial<Tooltip
};
}
const INITIAL_STATE: TooltipContainerState = {
style: { transform: '', pointerEvents: 'none' },
isHovering: false,
isPinned: false,
contents: null,
plot: null,
dismiss: () => {},
};
function initState(): TooltipContainerState {
return {
style: { transform: '', pointerEvents: 'none' },
isHovering: false,
isPinned: false,
contents: null,
plot: null,
dismiss: () => {},
};
}
// min px width that triggers zoom
const MIN_ZOOM_DIST = 5;
@ -105,13 +109,16 @@ export const TooltipPlugin2 = ({
queryZoom,
maxWidth,
maxHeight,
syncTooltip = () => false,
}: TooltipPlugin2Props) => {
const domRef = useRef<HTMLDivElement>(null);
const portalRoot = useRef<HTMLElement | null>(null);
const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, INITIAL_STATE);
if (portalRoot.current == null) {
portalRoot.current = getPortalContainer();
}
const { boostZIndex } = useContext(LayoutItemContext);
useEffect(() => (isPinned ? boostZIndex() : undefined), [isPinned]);
const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState);
const sizeRef = useRef<TooltipContainerSize>();
@ -150,20 +157,19 @@ export const TooltipPlugin2 = ({
let _isPinned = isPinned;
let _style = style;
let plotVisible = false;
const updateHovering = () => {
_isHovering = closestSeriesIdx != null || (hoverMode === TooltipHoverMode.xAll && _someSeriesIdx);
if (viaSync) {
_isHovering = plotVisible && _someSeriesIdx && syncTooltip();
} else {
_isHovering = closestSeriesIdx != null || (hoverMode === TooltipHoverMode.xAll && _someSeriesIdx);
}
};
let offsetX = 0;
let offsetY = 0;
let containRect = {
lft: 0,
top: 0,
rgt: screen.width,
btm: screen.height,
};
let selectedRange: TimeRange2 | null = null;
let seriesIdxs: Array<number | null> = plot?.cursor.idxs!.slice()!;
let closestSeriesIdx: number | null = null;
@ -231,7 +237,6 @@ export const TooltipPlugin2 = ({
setState(state);
selectedRange = null;
viaSync = false;
};
const dismiss = () => {
@ -293,58 +298,11 @@ export const TooltipPlugin2 = ({
}
}
});
const haltAncestorId = 'pageContent';
const scrollbarWidth = 16;
// if we're in a container that can clip the tooltip, we should try to stay within that rather than window edges
u.over.addEventListener(
'mouseenter',
() => {
// clamp to viewport bounds
let htmlEl = document.documentElement;
let winWid = htmlEl.clientWidth - scrollbarWidth;
let winHgt = htmlEl.clientHeight - scrollbarWidth;
let lft = 0,
top = 0,
rgt = winWid,
btm = winHgt;
// find nearest scrollable container where overflow is not visible, (stop at #pageContent)
let par: HTMLElement | null = u.root;
while (par != null && par.id !== haltAncestorId) {
let style = getComputedStyle(par);
let overflowX = style.getPropertyValue('overflow-x');
let overflowY = style.getPropertyValue('overflow-y');
if (overflowX !== 'visible' || overflowY !== 'visible') {
let rect = par.getBoundingClientRect();
lft = Math.max(rect.x, lft);
top = Math.max(rect.y, top);
rgt = Math.min(lft + rect.width, rgt);
btm = Math.min(top + rect.height, btm);
break;
}
par = par.parentElement;
}
containRect.lft = lft;
containRect.top = top;
containRect.rgt = rgt;
containRect.btm = btm;
},
{ capture: true }
);
});
config.addHook('setSelect', (u) => {
let e = u.cursor!.event;
if (e != null && (clientZoom || queryZoom != null)) {
if (maybeZoomAction(e)) {
if (!viaSync && (clientZoom || queryZoom != null)) {
if (maybeZoomAction(u.cursor!.event)) {
if (clientZoom && yDrag) {
if (u.select.height >= MIN_ZOOM_DIST) {
for (let key in u.scales!) {
@ -427,6 +385,8 @@ export const TooltipPlugin2 = ({
// TODO: we only need this for multi/all mode?
config.addHook('setSeries', (u, seriesIdx) => {
closestSeriesIdx = seriesIdx;
viaSync = u.cursor.event == null;
updateHovering();
scheduleRender();
});
@ -436,78 +396,107 @@ export const TooltipPlugin2 = ({
seriesIdxs = _plot?.cursor!.idxs!.slice()!;
_someSeriesIdx = seriesIdxs.some((v, i) => i > 0 && v != null);
viaSync = u.cursor.event == null;
updateHovering();
scheduleRender();
});
const scrollbarWidth = 16;
let winWid = 0;
let winHgt = 0;
const updateWinSize = () => {
_isHovering && !_isPinned && dismiss();
winWid = window.innerWidth - scrollbarWidth;
winHgt = window.innerHeight - scrollbarWidth;
};
const updatePlotVisible = () => {
plotVisible =
_plot!.rect.bottom <= winHgt && _plot!.rect.top >= 0 && _plot!.rect.left >= 0 && _plot!.rect.right <= winWid;
};
updateWinSize();
config.addHook('ready', updatePlotVisible);
// fires on mousemoves
config.addHook('setCursor', (u) => {
let { left = -10, top = -10, event } = u.cursor;
viaSync = u.cursor.event == null;
if (!_isHovering) {
return;
}
let { left = -10, top = -10 } = u.cursor;
if (left >= 0 || top >= 0) {
viaSync = event == null;
let clientX = u.rect.left + left;
let clientY = u.rect.top + top;
let transform = '';
// this means it's a synthetic event from uPlot's sync
if (viaSync) {
// TODO: smarter positioning here to avoid viewport clipping?
transform = `translateX(${left}px) translateY(${u.rect.height / 2}px) translateY(-50%)`;
} else {
let { width, height } = sizeRef.current!;
width += TOOLTIP_OFFSET;
height += TOOLTIP_OFFSET;
let { width, height } = sizeRef.current!;
let clientX = u.rect.left + left;
let clientY = u.rect.top + top;
width += TOOLTIP_OFFSET;
height += TOOLTIP_OFFSET;
if (offsetY !== 0) {
if (clientY + height < containRect.btm || clientY - height < 0) {
offsetY = 0;
} else if (offsetY !== -height) {
offsetY = -height;
}
} else {
if (clientY + height > containRect.btm && clientY - height >= 0) {
offsetY = -height;
}
if (offsetY !== 0) {
if (clientY + height < winHgt || clientY - height < 0) {
offsetY = 0;
} else if (offsetY !== -height) {
offsetY = -height;
}
} else {
if (clientY + height > winHgt && clientY - height >= 0) {
offsetY = -height;
}
}
if (offsetX !== 0) {
if (clientX + width < containRect.rgt || clientX - width < 0) {
offsetX = 0;
} else if (offsetX !== -width) {
offsetX = -width;
}
} else {
if (clientX + width > containRect.rgt && clientX - width >= 0) {
offsetX = -width;
}
if (offsetX !== 0) {
if (clientX + width < winWid || clientX - width < 0) {
offsetX = 0;
} else if (offsetX !== -width) {
offsetX = -width;
}
} else {
if (clientX + width > winWid && clientX - width >= 0) {
offsetX = -width;
}
}
const shiftX = left + (offsetX === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
const shiftY = top + (offsetY === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
const shiftX = clientX + (offsetX === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
const shiftY = clientY + (offsetY === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
const reflectX = offsetX === 0 ? '' : 'translateX(-100%)';
const reflectY = offsetY === 0 ? '' : 'translateY(-100%)';
const reflectX = offsetX === 0 ? '' : 'translateX(-100%)';
const reflectY = offsetY === 0 ? '' : 'translateY(-100%)';
// TODO: to a transition only when switching sides
// transition: transform 100ms;
// TODO: to a transition only when switching sides
// transition: transform 100ms;
transform = `translateX(${shiftX}px) ${reflectX} translateY(${shiftY}px) ${reflectY}`;
}
transform = `translateX(${shiftX}px) ${reflectX} translateY(${shiftY}px) ${reflectY}`;
if (_isHovering) {
if (domRef.current != null) {
domRef.current.style.transform = transform;
} else {
_style.transform = transform;
scheduleRender();
}
if (domRef.current != null) {
domRef.current.style.transform = transform;
} else {
_style.transform = transform;
scheduleRender();
}
}
});
const onscroll = () => {
updatePlotVisible();
_isHovering && !_isPinned && dismiss();
};
window.addEventListener('resize', updateWinSize);
window.addEventListener('scroll', onscroll, true);
return () => {
window.removeEventListener('resize', updateWinSize);
window.removeEventListener('scroll', onscroll, true);
};
}, [config]);
useLayoutEffect(() => {
@ -524,7 +513,7 @@ export const TooltipPlugin2 = ({
{isPinned && <CloseButton onClick={dismiss} />}
{contents}
</div>,
plot.over
portalRoot.current
);
}

@ -66,15 +66,16 @@ export function addTooltipOptions<T extends OptionsWithTooltip>(
settings: {
integer: true,
},
showIf: (options: T) => options.tooltip?.mode !== TooltipDisplayMode.None,
showIf: (options: T) => false, // options.tooltip?.mode !== TooltipDisplayMode.None,
})
.addNumberInput({
path: 'tooltip.maxHeight',
name: 'Max height',
category,
defaultValue: 600,
settings: {
integer: true,
},
showIf: (options: T) => options.tooltip?.mode !== TooltipDisplayMode.None,
showIf: (options: T) => false, //options.tooltip?.mode !== TooltipDisplayMode.None,
});
}

@ -5,7 +5,7 @@ import { alpha } from '@grafana/data/src/themes/colorManipulator';
import { TimelineValueAlignment, VisibilityMode } from '@grafana/schema';
import { FIXED_UNIT } from '@grafana/ui';
import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute';
import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree';
import { Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree';
import { FieldConfig as StateTimeLineFieldConfig } from 'app/plugins/panel/state-timeline/panelcfg.gen';
import { FieldConfig as StatusHistoryFieldConfig } from 'app/plugins/panel/status-history/panelcfg.gen';
@ -384,7 +384,7 @@ export function getConfig(opts: TimelineCoreOptions) {
});
};
function setHovered(cx: number, cy: number, cys: number[]) {
function setHovered(cx: number, cy: number, viaSync = false) {
hovered.fill(null);
hoveredAtCursor = null;
@ -392,19 +392,21 @@ export function getConfig(opts: TimelineCoreOptions) {
return;
}
for (let i = 0; i < cys.length; i++) {
let cy2 = cys[i];
qt.get(cx, cy2, 1, 1, (o) => {
if (pointWithin(cx, cy2, o.x, o.y, o.x + o.w, o.y + o.h)) {
// first gets all items in all quads intersected by a 1px wide by 10k high rect at the x cursor position and 0 y position.
// (we use 10k instead of plot area height for simplicity and not having to pass around the uPlot instance)
qt.get(cx, 0, uPlot.pxRatio, 1e4, (o) => {
// filter only rects that intersect along x dir
if (cx >= o.x && cx <= o.x + o.w) {
// if also intersect along y dir, set both "direct hovered" and "one-of hovered"
if (cy >= o.y && cy <= o.y + o.h) {
hovered[o.sidx] = hoveredAtCursor = o;
}
// else only set "one-of hovered" (no "direct hovered") in multi mode or when synced
else if (hoverMulti || viaSync) {
hovered[o.sidx] = o;
if (Math.abs(cy - cy2) <= o.h / 2) {
hoveredAtCursor = o;
}
}
});
}
}
});
}
const cursor: uPlot.Cursor = {
@ -426,7 +428,7 @@ export function getConfig(opts: TimelineCoreOptions) {
let prevHovered = hoveredAtCursor;
setHovered(cx, cy, hoverMulti ? yMids : [cy]);
setHovered(cx, cy, u.cursor.event == null);
if (hoveredAtCursor != null) {
if (hoveredAtCursor !== prevHovered) {

@ -1,12 +1,12 @@
// this file is pretty much a copy-paste of TimeSeriesPanel.tsx :(
// with some extra renderers passed to the <TimeSeries> component
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useCallback } from 'react';
import uPlot from 'uplot';
import { DashboardCursorSync, Field, getDisplayProcessor, getLinksSupplier, PanelProps } from '@grafana/data';
import { Field, getDisplayProcessor, getLinksSupplier, PanelProps } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { DashboardCursorSync, TooltipDisplayMode } from '@grafana/schema';
import { TooltipPlugin, TooltipPlugin2, UPlotConfigBuilder, usePanelContext, useTheme2, ZoomPlugin } from '@grafana/ui';
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder';
@ -46,6 +46,13 @@ export const CandlestickPanel = ({
const theme = useTheme2();
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const info = useMemo(() => {
return prepareCandlestickFields(data.series, options, theme, timeRange);
}, [data.series, options, theme, timeRange]);
@ -233,9 +240,8 @@ export const CandlestickPanel = ({
}
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
const showNewVizTooltips =
config.featureToggles.newVizTooltips && (sync == null || sync() !== DashboardCursorSync.Tooltip);
const enableAnnotationCreation = Boolean(canAddAnnotations?.());
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
return (
<TimeSeries
@ -272,11 +278,8 @@ export const CandlestickPanel = ({
}
queryZoom={onChangeTimeRange}
clientZoom={true}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => {
if (viaSync) {
return null;
}
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
@ -296,7 +299,7 @@ export const CandlestickPanel = ({
seriesFrame={alignedDataFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
annotate={enableAnnotationCreation ? annotate : undefined}

@ -60,6 +60,13 @@ export const HeatmapPanel = ({
const styles = useStyles2(getStyles);
const { sync, canAddAnnotations } = usePanelContext();
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
@ -159,8 +166,7 @@ export const HeatmapPanel = ({
// ugh
const dataRef = useRef(info);
dataRef.current = info;
const showNewVizTooltips =
config.featureToggles.newVizTooltips && (sync == null || sync() !== DashboardCursorSync.Tooltip);
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
const builder = useMemo(() => {
const scaleConfig: ScaleDistributionConfig = dataRef.current?.heatmap?.fields[1].config?.custom?.scaleDistribution;
@ -243,11 +249,8 @@ export const HeatmapPanel = ({
config={builder}
hoverMode={TooltipHoverMode.xyOne}
queryZoom={onChangeTimeRange}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (viaSync) {
return null;
}
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
@ -263,7 +266,7 @@ export const HeatmapPanel = ({
return (
<HeatmapHoverView
mode={options.tooltip.mode}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
dataRef={dataRef}

@ -429,17 +429,18 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(HeatmapPanel)
settings: {
integer: true,
},
showIf: (options) => config.featureToggles.newVizTooltips && options.tooltip?.mode !== TooltipDisplayMode.None,
showIf: (options) => false, // config.featureToggles.newVizTooltips && options.tooltip?.mode !== TooltipDisplayMode.None,
});
builder.addNumberInput({
path: 'tooltip.maxHeight',
name: 'Max height',
category,
defaultValue: 600,
settings: {
integer: true,
},
showIf: (options) => config.featureToggles.newVizTooltips && options.tooltip?.mode !== TooltipDisplayMode.None,
showIf: (options) => false, // config.featureToggles.newVizTooltips && options.tooltip?.mode !== TooltipDisplayMode.None,
});
category = ['Legend'];

@ -52,6 +52,13 @@ export const StateTimelinePanel = ({
}: TimelinePanelProps) => {
const theme = useTheme2();
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
const isToolTipOpen = useRef<boolean>(false);
@ -163,8 +170,7 @@ export const StateTimelinePanel = ({
}
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
const showNewVizTooltips =
config.featureToggles.newVizTooltips && (sync == null || sync() !== DashboardCursorSync.Tooltip);
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
return (
<TimelineChart
@ -202,13 +208,12 @@ export const StateTimelinePanel = ({
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={TooltipHoverMode.xOne}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
}
queryZoom={onChangeTimeRange}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (viaSync) {
return null;
}
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
@ -228,7 +233,7 @@ export const StateTimelinePanel = ({
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}

@ -49,6 +49,13 @@ export const StatusHistoryPanel = ({
}: TimelinePanelProps) => {
const theme = useTheme2();
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const oldConfig = useRef<UPlotConfigBuilder | undefined>(undefined);
const isToolTipOpen = useRef<boolean>(false);
@ -192,8 +199,7 @@ export const StatusHistoryPanel = ({
);
}
const showNewVizTooltips =
config.featureToggles.newVizTooltips && (sync == null || sync() !== DashboardCursorSync.Tooltip);
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
return (
<TimelineChart
@ -230,13 +236,12 @@ export const StatusHistoryPanel = ({
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={TooltipHoverMode.xyOne}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
}
queryZoom={onChangeTimeRange}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (viaSync) {
return null;
}
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
@ -256,7 +261,7 @@ export const StatusHistoryPanel = ({
seriesFrame={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useCallback } from 'react';
import { PanelProps, DataFrameType, DashboardCursorSync } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
@ -51,11 +51,17 @@ export const TimeSeriesPanel = ({
}, [frames, id]);
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
const showNewVizTooltips =
config.featureToggles.newVizTooltips && (sync == null || sync() !== DashboardCursorSync.Tooltip);
const showNewVizTooltips = Boolean(config.featureToggles.newVizTooltips);
// temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2
const [newAnnotationRange, setNewAnnotationRange] = useState<TimeRange2 | null>(null);
// TODO: we should just re-init when this changes, and have this be a static setting
const syncTooltip = useCallback(
() => sync?.() === DashboardCursorSync.Tooltip,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (!frames || suggestions) {
return (
<PanelDataErrorView
@ -102,7 +108,7 @@ export const TimeSeriesPanel = ({
return (
<>
{!showNewVizTooltips && <KeyboardPlugin config={uplotConfig} />}
<KeyboardPlugin config={uplotConfig} />
{options.tooltip.mode === TooltipDisplayMode.None || (
<>
{showNewVizTooltips ? (
@ -113,11 +119,8 @@ export const TimeSeriesPanel = ({
}
queryZoom={onChangeTimeRange}
clientZoom={true}
syncTooltip={syncTooltip}
render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync) => {
if (viaSync) {
return null;
}
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
@ -138,7 +141,7 @@ export const TimeSeriesPanel = ({
seriesFrame={alignedDataFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={options.tooltip.mode}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
annotate={enableAnnotationCreation ? annotate : undefined}

@ -4,9 +4,9 @@ import { createPortal } from 'react-dom';
import tinycolor from 'tinycolor2';
import uPlot from 'uplot';
import { arrayToDataFrame, colorManipulator, DataFrame, DataTopic, GrafanaTheme2 } from '@grafana/data';
import { arrayToDataFrame, colorManipulator, DataFrame, DataTopic } from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { DEFAULT_ANNOTATION_COLOR, UPlotConfigBuilder, useStyles2, useTheme2 } from '@grafana/ui';
import { DEFAULT_ANNOTATION_COLOR, getPortalContainer, UPlotConfigBuilder, useStyles2, useTheme2 } from '@grafana/ui';
import { AnnotationMarker2 } from './annotations2/AnnotationMarker2';
@ -65,6 +65,8 @@ export const AnnotationsPlugin2 = ({
}: AnnotationsPluginProps) => {
const [plot, setPlot] = useState<uPlot>();
const [portalRoot] = useState(() => getPortalContainer());
const styles = useStyles2(getStyles);
const getColorByName = useTheme2().visualization.getColorByName;
@ -221,9 +223,10 @@ export const AnnotationsPlugin2 = ({
annoVals={vals}
className={className}
style={style}
timezone={timeZone}
timeZone={timeZone}
key={`${frameIdx}:${i}`}
exitWipEdit={isWip ? exitWipEdit : null}
portalRoot={portalRoot}
/>
);
}
@ -238,14 +241,14 @@ export const AnnotationsPlugin2 = ({
return null;
};
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = () => ({
annoMarker: css({
position: 'absolute',
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderBottomWidth: '6px',
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottomWidth: '5px',
borderBottomStyle: 'solid',
transform: 'translateX(-50%)',
cursor: 'pointer',

@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import React, { useContext, useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import React, { useContext, useEffect, useRef } from 'react';
import { useAsyncFn, useClickAway } from 'react-use';
import { AnnotationEventUIModel, GrafanaTheme2 } from '@grafana/data';
import { AnnotationEventUIModel, GrafanaTheme2, dateTimeFormat, systemDateFormats } from '@grafana/data';
import {
Button,
Field,
@ -20,7 +20,7 @@ import { getAnnotationTags } from 'app/features/annotations/api';
interface Props {
annoVals: Record<string, any[]>;
annoIdx: number;
timeFormatter: (v: number) => string;
timeZone: string;
dismiss: () => void;
}
@ -29,25 +29,35 @@ interface AnnotationEditFormDTO {
tags: string[];
}
export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeFormatter, ...otherProps }: Props) => {
export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeZone, ...otherProps }: Props) => {
const styles = useStyles2(getStyles);
const panelContext = usePanelContext();
const { onAnnotationCreate, onAnnotationUpdate } = usePanelContext();
const clickAwayRef = useRef(null);
useClickAway(clickAwayRef, dismiss);
const layoutCtx = useContext(LayoutItemContext);
useEffect(() => layoutCtx.boostZIndex(), [layoutCtx]);
const [createAnnotationState, createAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => {
const result = await panelContext.onAnnotationCreate!(event);
const result = await onAnnotationCreate!(event);
dismiss();
return result;
});
const [updateAnnotationState, updateAnnotation] = useAsyncFn(async (event: AnnotationEventUIModel) => {
const result = await panelContext.onAnnotationUpdate!(event);
const result = await onAnnotationUpdate!(event);
dismiss();
return result;
});
const timeFormatter = (value: number) =>
dateTimeFormat(value, {
format: systemDateFormats.fullDate,
timeZone,
});
const isUpdatingAnnotation = annoVals.id?.[annoIdx] != null;
const isRegionAnnotation = annoVals.isRegion?.[annoIdx];
const operation = isUpdatingAnnotation ? updateAnnotation : createAnnotation;
@ -68,7 +78,7 @@ export const AnnotationEditor2 = ({ annoVals, annoIdx, dismiss, timeFormatter, .
// Annotation editor
return (
<div className={styles.editor} {...otherProps}>
<div ref={clickAwayRef} className={styles.editor} {...otherProps}>
<div className={styles.header}>
<HorizontalGroup justify={'space-between'} align={'center'}>
<div>{isUpdatingAnnotation ? 'Edit annotation' : 'Add annotation'}</div>

@ -1,10 +1,12 @@
import { css } from '@emotion/css';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import useClickAway from 'react-use/lib/useClickAway';
import { flip, shift, autoUpdate } from '@floating-ui/dom';
import { useFloating } from '@floating-ui/react';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { dateTimeFormat, GrafanaTheme2, systemDateFormats } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { usePanelContext, useStyles2 } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui';
import { AnnotationEditor2 } from './AnnotationEditor2';
import { AnnotationTooltip2 } from './AnnotationTooltip2';
@ -14,131 +16,94 @@ interface AnnoBoxProps {
annoIdx: number;
style: React.CSSProperties | null;
className: string;
timezone: TimeZone;
timeZone: TimeZone;
exitWipEdit?: null | (() => void);
portalRoot: HTMLElement;
}
const STATE_DEFAULT = 0;
const STATE_EDITING = 1;
const STATE_HOVERED = 2;
export const AnnotationMarker2 = ({ annoVals, annoIdx, className, style, exitWipEdit, timezone }: AnnoBoxProps) => {
const { canEditAnnotations, canDeleteAnnotations, ...panelCtx } = usePanelContext();
export const AnnotationMarker2 = ({
annoVals,
annoIdx,
className,
style,
exitWipEdit,
timeZone,
portalRoot,
}: AnnoBoxProps) => {
const styles = useStyles2(getStyles);
const [state, setState] = useState(STATE_DEFAULT);
const clickAwayRef = useRef(null);
useClickAway(clickAwayRef, () => {
if (state === STATE_EDITING) {
setIsEditingWrap(false);
}
const [state, setState] = useState(exitWipEdit != null ? STATE_EDITING : STATE_DEFAULT);
const { refs, floatingStyles } = useFloating({
open: true,
placement: 'bottom',
middleware: [
flip({
fallbackAxisSideDirection: 'end',
// see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false,
boundary: document.body,
}),
shift(),
],
whileElementsMounted: autoUpdate,
strategy: 'fixed',
});
const domRef = React.createRef<HTMLDivElement>();
// similar to TooltipPlugin2, when editing annotation (pinned), it should boost z-index
const setIsEditingWrap = useCallback(
(isEditing: boolean) => {
setState(isEditing ? STATE_EDITING : STATE_DEFAULT);
if (!isEditing && exitWipEdit != null) {
exitWipEdit();
}
},
[exitWipEdit]
);
const onAnnotationEdit = useCallback(() => {
setIsEditingWrap(true);
}, [setIsEditingWrap]);
const onAnnotationDelete = useCallback(() => {
if (panelCtx.onAnnotationDelete) {
panelCtx.onAnnotationDelete(annoVals.id?.[annoIdx]);
}
}, [annoIdx, annoVals.id, panelCtx]);
const timeFormatter = useCallback(
(value: number) => {
return dateTimeFormat(value, {
format: systemDateFormats.fullDate,
timeZone: timezone,
});
},
[timezone]
);
useLayoutEffect(
() => {
if (exitWipEdit != null) {
setIsEditingWrap(true);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const renderAnnotationTooltip = useCallback(() => {
let dashboardUID = annoVals.dashboardUID?.[annoIdx];
return (
const contents =
state === STATE_HOVERED ? (
<AnnotationTooltip2
timeFormatter={timeFormatter}
onEdit={onAnnotationEdit}
onDelete={onAnnotationDelete}
canEdit={canEditAnnotations ? canEditAnnotations(dashboardUID) : false}
canDelete={canDeleteAnnotations ? canDeleteAnnotations(dashboardUID) : false}
annoIdx={annoIdx}
annoVals={annoVals}
timeZone={timeZone}
onEdit={() => setState(STATE_EDITING)}
/>
);
}, [
timeFormatter,
onAnnotationEdit,
onAnnotationDelete,
canEditAnnotations,
annoVals,
annoIdx,
canDeleteAnnotations,
]);
const renderAnnotationEditor = useCallback(() => {
return (
) : state === STATE_EDITING ? (
<AnnotationEditor2
dismiss={() => setIsEditingWrap(false)}
timeFormatter={timeFormatter}
annoIdx={annoIdx}
annoVals={annoVals}
timeZone={timeZone}
dismiss={() => {
exitWipEdit?.();
setState(STATE_DEFAULT);
}}
/>
);
}, [annoIdx, annoVals, timeFormatter, setIsEditingWrap]);
) : null;
return (
<div
ref={domRef}
ref={refs.setReference}
className={className}
style={style!}
onMouseEnter={() => state !== STATE_EDITING && setState(STATE_HOVERED)}
onMouseLeave={() => state !== STATE_EDITING && setState(STATE_DEFAULT)}
>
<div className={styles.annoInfo} ref={clickAwayRef}>
{state === STATE_HOVERED && renderAnnotationTooltip()}
{state === STATE_EDITING && renderAnnotationEditor()}
</div>
{contents &&
createPortal(
<div ref={refs.setFloating} className={styles.annoBox} style={floatingStyles}>
{contents}
</div>,
portalRoot
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
annoInfo: css({
background: theme.colors.background.secondary,
minWidth: '300px',
// maxWidth: '400px',
// NOTE: shares much with TooltipPlugin2
annoBox: css({
top: 0,
left: 0,
zIndex: theme.zIndex.tooltip,
borderRadius: theme.shape.radius.default,
position: 'absolute',
top: '5px',
left: '50%',
transform: 'translateX(-50%)',
background: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
boxShadow: theme.shadows.z2,
userSelect: 'text',
minWidth: '300px',
}),
});

@ -1,34 +1,39 @@
import { css } from '@emotion/css';
import React, { useContext, useEffect } from 'react';
import { GrafanaTheme2, textUtil } from '@grafana/data';
import { HorizontalGroup, IconButton, LayoutItemContext, Tag, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, dateTimeFormat, systemDateFormats, textUtil } from '@grafana/data';
import { HorizontalGroup, IconButton, LayoutItemContext, Tag, usePanelContext, useStyles2 } from '@grafana/ui';
import alertDef from 'app/features/alerting/state/alertDef';
interface Props {
annoVals: Record<string, any[]>;
annoIdx: number;
timeFormatter: (v: number) => string;
canEdit: boolean;
canDelete: boolean;
timeZone: string;
onEdit: () => void;
onDelete: () => void;
}
export const AnnotationTooltip2 = ({
annoVals,
annoIdx,
timeFormatter,
canEdit,
canDelete,
onEdit,
onDelete,
}: Props) => {
const retFalse = () => false;
export const AnnotationTooltip2 = ({ annoVals, annoIdx, timeZone, onEdit }: Props) => {
const annoId = annoVals.id?.[annoIdx];
const styles = useStyles2(getStyles);
const { canEditAnnotations = retFalse, canDeleteAnnotations = retFalse, onAnnotationDelete } = usePanelContext();
const dashboardUID = annoVals.dashboardUID?.[annoIdx];
const canEdit = canEditAnnotations(dashboardUID);
const canDelete = canDeleteAnnotations(dashboardUID) && onAnnotationDelete != null;
const layoutCtx = useContext(LayoutItemContext);
useEffect(() => layoutCtx.boostZIndex(), [layoutCtx]);
const timeFormatter = (value: number) =>
dateTimeFormat(value, {
format: systemDateFormats.fullDate,
timeZone,
});
let time = timeFormatter(annoVals.time[annoIdx]);
let text = annoVals.text[annoIdx];
@ -75,9 +80,8 @@ export const AnnotationTooltip2 = ({
<IconButton
name={'trash-alt'}
size={'sm'}
onClick={onDelete}
onClick={() => onAnnotationDelete(annoId)}
tooltip="Delete"
disabled={!annoVals.id?.[annoIdx]}
/>
)}
</div>

Loading…
Cancel
Save