From 6e6b9a62a20ec644dcb19b968395c61296551eef Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 23 Feb 2024 16:18:24 -0600 Subject: [PATCH] VizTooltips: Use global portal (#81986) Co-authored-by: Adela Almasan --- .../uPlot/plugins/TooltipPlugin2.tsx | 239 +++++++++--------- .../src/options/builder/tooltip.tsx | 5 +- .../core/components/TimelineChart/timeline.ts | 32 +-- .../panel/candlestick/CandlestickPanel.tsx | 25 +- .../plugins/panel/heatmap/HeatmapPanel.tsx | 17 +- public/app/plugins/panel/heatmap/module.tsx | 5 +- .../state-timeline/StateTimelinePanel.tsx | 21 +- .../status-history/StatusHistoryPanel.tsx | 21 +- .../panel/timeseries/TimeSeriesPanel.tsx | 21 +- .../timeseries/plugins/AnnotationsPlugin2.tsx | 17 +- .../annotations2/AnnotationEditor2.tsx | 28 +- .../annotations2/AnnotationMarker2.tsx | 159 +++++------- .../annotations2/AnnotationTooltip2.tsx | 38 +-- 13 files changed, 311 insertions(+), 317 deletions(-) diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index 3b755bc379f..f2ce44ecdce 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.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 {}, -}; +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(null); + const portalRoot = useRef(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(); @@ -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 = 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%)`; + let { width, height } = sizeRef.current!; + + width += TOOLTIP_OFFSET; + height += TOOLTIP_OFFSET; + + if (offsetY !== 0) { + if (clientY + height < winHgt || clientY - height < 0) { + offsetY = 0; + } else if (offsetY !== -height) { + offsetY = -height; + } } else { - let { width, height } = sizeRef.current!; - - width += TOOLTIP_OFFSET; - height += TOOLTIP_OFFSET; - - let clientX = u.rect.left + left; - let clientY = u.rect.top + top; - - 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 (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; - } - } - - const shiftX = left + (offsetX === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET); - const shiftY = top + (offsetY === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET); - - const reflectX = offsetX === 0 ? '' : 'translateX(-100%)'; - const reflectY = offsetY === 0 ? '' : 'translateY(-100%)'; - - // TODO: to a transition only when switching sides - // transition: transform 100ms; - - 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 (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 = 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%)'; + + // TODO: to a transition only when switching sides + // transition: transform 100ms; + + transform = `translateX(${shiftX}px) ${reflectX} translateY(${shiftY}px) ${reflectY}`; + + 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 && } {contents} , - plot.over + portalRoot.current ); } diff --git a/packages/grafana-ui/src/options/builder/tooltip.tsx b/packages/grafana-ui/src/options/builder/tooltip.tsx index a5091b48b53..0ff12becc3e 100644 --- a/packages/grafana-ui/src/options/builder/tooltip.tsx +++ b/packages/grafana-ui/src/options/builder/tooltip.tsx @@ -66,15 +66,16 @@ export function addTooltipOptions( 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, }); } diff --git a/public/app/core/components/TimelineChart/timeline.ts b/public/app/core/components/TimelineChart/timeline.ts index e5fcb3555c8..5afd52afbdf 100644 --- a/public/app/core/components/TimelineChart/timeline.ts +++ b/public/app/core/components/TimelineChart/timeline.ts @@ -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)) { - hovered[o.sidx] = o; - - if (Math.abs(cy - cy2) <= o.h / 2) { - hoveredAtCursor = o; - } + // 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; + } + } + }); } 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) { diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index 2e1d6ab208e..fa71996e3b6 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -1,12 +1,12 @@ // this file is pretty much a copy-paste of TimeSeriesPanel.tsx :( // with some extra renderers passed to the 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 ( { - 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} diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index a9f02d7f6f9..018ddca15cc 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -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(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 ( (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']; diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index cf3539fd4d2..0cdc5c0ec96 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -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(undefined); const isToolTipOpen = useRef(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 ( { - 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} diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index 241e0f6889d..29192589c10 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -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(undefined); const isToolTipOpen = useRef(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 ( { - 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} diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 630701a4361..50ee36677b3 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -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(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 ( - {!showNewVizTooltips && } + {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} diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx index c1e616aeb22..8c0313d1ca4 100644 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin2.tsx @@ -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(); + 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', diff --git a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx index 6f56a9a1a18..1e01344b463 100644 --- a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx +++ b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationEditor2.tsx @@ -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; 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 ( -
+
{isUpdatingAnnotation ? 'Edit annotation' : 'Add annotation'}
diff --git a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx index e0af375355b..db27a162b9f 100644 --- a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx +++ b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationMarker2.tsx @@ -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(); - - // 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 ? ( setState(STATE_EDITING)} /> - ); - }, [ - timeFormatter, - onAnnotationEdit, - onAnnotationDelete, - canEditAnnotations, - annoVals, - annoIdx, - canDeleteAnnotations, - ]); - - const renderAnnotationEditor = useCallback(() => { - return ( + ) : state === STATE_EDITING ? ( setIsEditingWrap(false)} - timeFormatter={timeFormatter} annoIdx={annoIdx} annoVals={annoVals} + timeZone={timeZone} + dismiss={() => { + exitWipEdit?.(); + setState(STATE_DEFAULT); + }} /> - ); - }, [annoIdx, annoVals, timeFormatter, setIsEditingWrap]); + ) : null; return (
state !== STATE_EDITING && setState(STATE_HOVERED)} onMouseLeave={() => state !== STATE_EDITING && setState(STATE_DEFAULT)} > -
- {state === STATE_HOVERED && renderAnnotationTooltip()} - {state === STATE_EDITING && renderAnnotationEditor()} -
+ {contents && + createPortal( +
+ {contents} +
, + portalRoot + )}
); }; 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', }), }); diff --git a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx index 1e53aafb997..0b72deb99f2 100644 --- a/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx +++ b/public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx @@ -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; 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 = ({ onAnnotationDelete(annoId)} tooltip="Delete" - disabled={!annoVals.id?.[annoIdx]} /> )}