diff --git a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.test.js b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.test.js index 921ef67d5d..1279b16fc8 100644 --- a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.test.js +++ b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.test.js @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import ViewingLayer, { dragTypes } from './ViewingLayer'; import { EUpdateTypes } from '../../../../utils/DraggableManager'; @@ -39,7 +39,7 @@ describe('', () => { }); it('throws if _root is not set', () => { - ref.current._root = null; + ref.current._setRoot(null); expect(() => ref.current._getDraggingBounds(dragTypes.REFRAME)).toThrow(); }); @@ -161,14 +161,25 @@ describe('', () => { }); describe('scrubber', () => { + beforeEach(() => { + // Ensure cursor is visible by default so we can test it gets hidden + // Must clone object to bypass React.memo shallow comparison + const newTime = { ...props.viewRange.time, cursor: 0.5 }; + const newViewRange = { ...props.viewRange, time: newTime }; + props = { ...props, viewRange: newViewRange }; + rerender(); + }); + it('prevents the cursor from being drawn on scrubber mouseover', () => { fireEvent.mouseEnter(container.querySelectorAll('[data-testid="scrubber"]')[0]); - expect(ref.current.state.preventCursorLine).toBe(true); + expect(container.querySelector('.ViewingLayer--cursorGuide')).not.toBeInTheDocument(); }); - it('prevents the cursor from being drawn on scrubber mouseleave', () => { + it('prevents the cursor from being drawn on scrubber mouseleave', async () => { fireEvent.mouseLeave(container.querySelectorAll('[data-testid="scrubber"]')[0]); - expect(ref.current.state.preventCursorLine).toBe(false); + await waitFor(() => + expect(container.querySelector('.ViewingLayer--cursorGuide')).toBeInTheDocument() + ); }); describe('drag start and update', () => { @@ -195,7 +206,7 @@ describe('', () => { }); }); - it('updates the view on drag end', () => { + it('updates the view on drag end', async () => { const [start, end] = props.viewRange.time.current; const cases = [ { @@ -207,13 +218,23 @@ describe('', () => { expectArgs: [start, 0.5, 'minimap'], }, ]; - cases.forEach(({ update, expectArgs }) => { - ref.current.setState({ preventCursorLine: true }); + + for (const { update, expectArgs } of cases) { + // Simulate hiding cursor first (e.g. dragging started) + fireEvent.mouseEnter(container.querySelectorAll('[data-testid="scrubber"]')[0]); + expect(container.querySelector('.ViewingLayer--cursorGuide')).not.toBeInTheDocument(); + + // End drag ref.current._handleScrubberDragEnd(update); - expect(ref.current.state.preventCursorLine).toBe(false); + + // Verify cursor returns + await waitFor(() => + expect(container.querySelector('.ViewingLayer--cursorGuide')).toBeInTheDocument() + ); + expect(update.manager.resetBounds).toHaveBeenCalled(); expect(props.updateViewRangeTime).toHaveBeenLastCalledWith(...expectArgs); - }); + } }); }); }); diff --git a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.tsx b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.tsx index cf33561646..7eefa42238 100644 --- a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.tsx +++ b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.tsx @@ -3,7 +3,7 @@ import { Button } from 'antd'; import cx from 'classnames'; -import * as React from 'react'; +import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react'; import GraphTicks from './GraphTicks'; import Scrubber from './Scrubber'; @@ -25,13 +25,6 @@ type ViewingLayerProps = { viewRange: IViewRange; }; -type ViewingLayerState = { - /** - * Cursor line should not be drawn when the mouse is over the scrubber handle. - */ - preventCursorLine: boolean; -}; - /** * Designate the tags for the different dragging managers. Exported for tests. */ @@ -70,84 +63,24 @@ function getNextViewLayout(start: number, position: number) { * `ViewingLayer` is rendered on top of the Canvas rendering of the minimap and * handles showing the current view range and handles mouse UX for modifying it. */ -export default class ViewingLayer extends React.PureComponent { - state: ViewingLayerState; - - _root: Element | TNil; +const ViewingLayer = forwardRef(function ViewingLayer(props: ViewingLayerProps, ref: React.Ref) { + const { height, numTicks, viewRange } = props; /** - * `_draggerReframe` handles clicking and dragging on the `ViewingLayer` to - * redefined the view range. - */ - _draggerReframe: DraggableManager; - - /** - * `_draggerStart` handles dragging the left scrubber to adjust the start of - * the view range. - */ - _draggerStart: DraggableManager; - - /** - * `_draggerEnd` handles dragging the right scrubber to adjust the end of - * the view range. + * Cursor line should not be drawn when the mouse is over the scrubber handle. */ - _draggerEnd: DraggableManager; - - constructor(props: ViewingLayerProps) { - super(props); + const [preventCursorLine, setPreventCursorLine] = useState(false); - this._draggerReframe = new DraggableManager({ - getBounds: this._getDraggingBounds, - onDragEnd: this._handleReframeDragEnd, - onDragMove: this._handleReframeDragUpdate, - onDragStart: this._handleReframeDragUpdate, - onMouseMove: this._handleReframeMouseMove, - onMouseLeave: this._handleReframeMouseLeave, - tag: dragTypes.REFRAME, - }); - - this._draggerStart = new DraggableManager({ - getBounds: this._getDraggingBounds, - onDragEnd: this._handleScrubberDragEnd, - onDragMove: this._handleScrubberDragUpdate, - onDragStart: this._handleScrubberDragUpdate, - onMouseEnter: this._handleScrubberEnterLeave, - onMouseLeave: this._handleScrubberEnterLeave, - tag: dragTypes.SHIFT_START, - }); + const _root = useRef(undefined); + const propsRef = useRef(props); + propsRef.current = props; - this._draggerEnd = new DraggableManager({ - getBounds: this._getDraggingBounds, - onDragEnd: this._handleScrubberDragEnd, - onDragMove: this._handleScrubberDragUpdate, - onDragStart: this._handleScrubberDragUpdate, - onMouseEnter: this._handleScrubberEnterLeave, - onMouseLeave: this._handleScrubberEnterLeave, - tag: dragTypes.SHIFT_END, - }); - - this._root = undefined; - this.state = { - preventCursorLine: false, - }; - } - - componentWillUnmount() { - this._draggerReframe.dispose(); - this._draggerEnd.dispose(); - this._draggerStart.dispose(); - } - - _setRoot = (elm: SVGElement | TNil) => { - this._root = elm; - }; - - _getDraggingBounds = (tag: string | TNil): DraggableBounds => { - if (!this._root) { + const _getDraggingBounds = (tag: string | TNil): DraggableBounds => { + if (!_root.current) { throw new Error('invalid state'); } - const { left: clientXLeft, width } = this._root.getBoundingClientRect(); - const [viewStart, viewEnd] = this.props.viewRange.time.current; + const { left: clientXLeft, width } = _root.current.getBoundingClientRect(); + const [viewStart, viewEnd] = propsRef.current.viewRange.time.current; let maxValue = 1; let minValue = 0; if (tag === dragTypes.SHIFT_START) { @@ -158,48 +91,48 @@ export default class ViewingLayer extends React.PureComponent { - this.props.updateNextViewRangeTime({ cursor: value }); + const _handleReframeMouseMove = ({ value }: DraggingUpdate) => { + propsRef.current.updateNextViewRangeTime({ cursor: value }); }; - _handleReframeMouseLeave = () => { - this.props.updateNextViewRangeTime({ cursor: null }); + const _handleReframeMouseLeave = () => { + propsRef.current.updateNextViewRangeTime({ cursor: null }); }; - _handleReframeDragUpdate = ({ value }: DraggingUpdate) => { + const _handleReframeDragUpdate = ({ value }: DraggingUpdate) => { const shift = value; - const { time } = this.props.viewRange; + const { time } = propsRef.current.viewRange; const anchor = time.reframe ? time.reframe.anchor : shift; const update = { reframe: { anchor, shift } }; - this.props.updateNextViewRangeTime(update); + propsRef.current.updateNextViewRangeTime(update); }; - _handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => { - const { time } = this.props.viewRange; + const _handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => { + const { time } = propsRef.current.viewRange; const anchor = time.reframe ? time.reframe.anchor : value; const [start, end] = value < anchor ? [value, anchor] : [anchor, value]; manager.resetBounds(); - this.props.updateViewRangeTime(start, end, 'minimap'); + propsRef.current.updateViewRangeTime(start, end, 'minimap'); }; - _handleScrubberEnterLeave = ({ type }: DraggingUpdate) => { - const preventCursorLine = type === EUpdateTypes.MouseEnter; - this.setState({ preventCursorLine }); + const _handleScrubberEnterLeave = ({ type }: DraggingUpdate) => { + const shouldPreventCursorLine = type === EUpdateTypes.MouseEnter; + setPreventCursorLine(shouldPreventCursorLine); }; - _handleScrubberDragUpdate = ({ event, tag, type, value }: DraggingUpdate) => { + const _handleScrubberDragUpdate = ({ event, tag, type, value }: DraggingUpdate) => { if (type === EUpdateTypes.DragStart) { event.stopPropagation(); } if (tag === dragTypes.SHIFT_START) { - this.props.updateNextViewRangeTime({ shiftStart: value }); + propsRef.current.updateNextViewRangeTime({ shiftStart: value }); } else if (tag === dragTypes.SHIFT_END) { - this.props.updateNextViewRangeTime({ shiftEnd: value }); + propsRef.current.updateNextViewRangeTime({ shiftEnd: value }); } }; - _handleScrubberDragEnd = ({ manager, tag, value }: DraggingUpdate) => { - const [viewStart, viewEnd] = this.props.viewRange.time.current; + const _handleScrubberDragEnd = ({ manager, tag, value }: DraggingUpdate) => { + const [viewStart, viewEnd] = propsRef.current.viewRange.time.current; let update: [number, number]; if (tag === dragTypes.SHIFT_START) { update = [value, viewEnd]; @@ -210,15 +143,16 @@ export default class ViewingLayer extends React.PureComponent { - this.props.updateViewRangeTime(0, 1); + const _resetTimeZoomClickHandler = () => { + propsRef.current.updateViewRangeTime(0, 1); }; /** @@ -227,7 +161,7 @@ export default class ViewingLayer extends React.PureComponent { const layout = getNextViewLayout(from, to); const cls = cx({ isShiftDrag: isShift, @@ -240,7 +174,7 @@ export default class ViewingLayer extends React.PureComponent, , ]; - } + }; - render() { - const { height, viewRange, numTicks } = this.props; - const { preventCursorLine } = this.state; - const { current, cursor, shiftStart, shiftEnd, reframe } = viewRange.time; - const haveNextTimeRange = shiftStart != null || shiftEnd != null || reframe != null; - const [viewStart, viewEnd] = current; - let leftInactive = 0; - if (viewStart) { - leftInactive = viewStart * 100; - } - let rightInactive = 100; - if (viewEnd) { - rightInactive = 100 - viewEnd * 100; - } - let cursorPosition: string | undefined; - if (!haveNextTimeRange && cursor != null && !preventCursorLine) { - cursorPosition = `${cursor * 100}%`; - } + /** + * `draggerReframe` handles clicking and dragging on the `ViewingLayer` to + * redefined the view range. + */ + const [draggerReframe] = useState(() => { + return new DraggableManager({ + getBounds: _getDraggingBounds, + onDragEnd: _handleReframeDragEnd, + onDragMove: _handleReframeDragUpdate, + onDragStart: _handleReframeDragUpdate, + onMouseMove: _handleReframeMouseMove, + onMouseLeave: _handleReframeMouseLeave, + tag: dragTypes.REFRAME, + }); + }); + + /** + * `draggerStart` handles dragging the left scrubber to adjust the start of + * the view range. + */ + const [draggerStart] = useState(() => { + return new DraggableManager({ + getBounds: _getDraggingBounds, + onDragEnd: _handleScrubberDragEnd, + onDragMove: _handleScrubberDragUpdate, + onDragStart: _handleScrubberDragUpdate, + onMouseEnter: _handleScrubberEnterLeave, + onMouseLeave: _handleScrubberEnterLeave, + tag: dragTypes.SHIFT_START, + }); + }); + + /** + * `draggerEnd` handles dragging the right scrubber to adjust the end of + * the view range. + */ + const [draggerEnd] = useState(() => { + return new DraggableManager({ + getBounds: _getDraggingBounds, + onDragEnd: _handleScrubberDragEnd, + onDragMove: _handleScrubberDragUpdate, + onDragStart: _handleScrubberDragUpdate, + onMouseEnter: _handleScrubberEnterLeave, + onMouseLeave: _handleScrubberEnterLeave, + tag: dragTypes.SHIFT_END, + }); + }); - return ( -
- {(viewStart !== 0 || viewEnd !== 1) && ( - + // Cleanup DraggableManager instances on unmount + useEffect(() => { + return () => { + draggerReframe.dispose(); + draggerStart.dispose(); + draggerEnd.dispose(); + }; + }, [draggerReframe, draggerStart, draggerEnd]); + + const setRootCallback = useCallback((el: SVGSVGElement | null) => { + _root.current = el; + }, []); + + // Expose instance methods via ref (backwards compatibility) + useImperativeHandle( + ref, + () => ({ + get _root() { + return _root.current; + }, + _setRoot: (el: Element | TNil) => { + _root.current = el; + }, + _getDraggingBounds, + _getMarkers, + _handleReframeMouseMove, + _handleReframeMouseLeave, + _handleReframeDragUpdate, + _handleReframeDragEnd, + _handleScrubberEnterLeave, + _handleScrubberDragUpdate, + _handleScrubberDragEnd, + }), + [] + ); + + const { current, cursor, shiftStart, shiftEnd, reframe } = viewRange.time; + const haveNextTimeRange = shiftStart != null || shiftEnd != null || reframe != null; + const [viewStart, viewEnd] = current; + let leftInactive = 0; + if (viewStart) { + leftInactive = viewStart * 100; + } + let rightInactive = 100; + if (viewEnd) { + rightInactive = 100 - viewEnd * 100; + } + let cursorPosition: string | undefined; + if (!haveNextTimeRange && cursor != null && !preventCursorLine) { + cursorPosition = `${cursor * 100}%`; + } + + return ( +
+ {(viewStart !== 0 || viewEnd !== 1) && ( + + )} + + {leftInactive > 0 && ( + )} - - {leftInactive > 0 && ( - - )} - {rightInactive > 0 && ( - - )} - - {cursorPosition && ( - - )} - {shiftStart != null && this._getMarkers(viewStart, shiftStart, true)} - {shiftEnd != null && this._getMarkers(viewEnd, shiftEnd, true)} - 0 && ( + - + {cursorPosition && ( + - {reframe != null && this._getMarkers(reframe.anchor, reframe.shift, false)} - - {/* fullOverlay updates the mouse cursor blocks mouse events */} - {haveNextTimeRange &&
} -
- ); - } -} + )} + {shiftStart != null && _getMarkers(viewStart, shiftStart, true)} + {shiftEnd != null && _getMarkers(viewEnd, shiftEnd, true)} + + + {reframe != null && _getMarkers(reframe.anchor, reframe.shift, false)} + + {/* fullOverlay updates the mouse cursor blocks mouse events */} + {haveNextTimeRange &&
} +
+ ); +}); + +export default React.memo(ViewingLayer);