From be70c0dcfac9ee53d9c73158aff9de664151e568 Mon Sep 17 00:00:00 2001 From: Everett Date: Fri, 4 Jan 2019 16:43:56 -0500 Subject: [PATCH] Add indent guides to trace timeline view (#172) (#297) * Add indent guides to trace timeline view (#172) Signed-off-by: Everett Ross * Add tests for connect functions, add more flow types Signed-off-by: Everett Ross * Consolidate ducks, remove redudant PropTypes, add event type Signed-off-by: Everett Ross * Rename hoverSpanId to hoverIndentGuideId Signed-off-by: Everett Ross * Derive props from span, use dataset over getAttribute Signed-off-by: Everett Ross Signed-off-by: vvvprabhakar --- .../TraceTimelineViewer/SpanBarRow.js | 25 +-- .../TraceTimelineViewer/SpanBarRow.test.js | 18 +- .../TraceTimelineViewer/SpanDetailRow.js | 2 +- .../TraceTimelineViewer/SpanDetailRow.test.js | 4 +- .../TraceTimelineViewer/SpanTreeOffset.css | 26 +++ .../TraceTimelineViewer/SpanTreeOffset.js | 139 +++++++++++-- .../SpanTreeOffset.test.js | 186 ++++++++++++++++++ .../VirtualizedTraceView.js | 15 +- .../VirtualizedTraceView.test.js | 9 +- .../TracePage/TraceTimelineViewer/duck.js | 35 +++- .../TraceTimelineViewer/duck.test.js | 41 ++++ .../jaeger-ui/src/types/trace-timeline.js | 1 + 12 files changed, 435 insertions(+), 66 deletions(-) create mode 100644 packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.test.js diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js index 6157853821..475c977e13 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanBarRow.js @@ -19,25 +19,24 @@ import IoAlert from 'react-icons/lib/io/alert'; import IoArrowRightA from 'react-icons/lib/io/arrow-right-a'; import TimelineRow from './TimelineRow'; +import { formatDuration } from './utils'; import SpanTreeOffset from './SpanTreeOffset'; import SpanBar from './SpanBar'; import Ticks from './Ticks'; +import type { Span } from '../../../types/trace'; + import './SpanBarRow.css'; type SpanBarRowProps = { className: string, color: string, columnDivision: number, - depth: number, isChildrenExpanded: boolean, isDetailExpanded: boolean, isMatchingFilter: boolean, - isParent: boolean, - label: string, onDetailToggled: string => void, onChildrenToggled: string => void, - operationName: string, numTicks: number, rpc: ?{ viewStart: number, @@ -46,9 +45,8 @@ type SpanBarRowProps = { operationName: string, serviceName: string, }, - serviceName: string, showErrorIcon: boolean, - spanID: string, + span: Span, viewEnd: number, viewStart: number, }; @@ -70,11 +68,11 @@ export default class SpanBarRow extends React.PureComponent { }; _detailToggle = () => { - this.props.onDetailToggled(this.props.spanID); + this.props.onDetailToggled(this.props.span.spanID); }; _childrenToggle = () => { - this.props.onChildrenToggled(this.props.spanID); + this.props.onChildrenToggled(this.props.span.spanID); }; render() { @@ -82,20 +80,18 @@ export default class SpanBarRow extends React.PureComponent { className, color, columnDivision, - depth, isChildrenExpanded, isDetailExpanded, isMatchingFilter, - isParent, - label, numTicks, - operationName, rpc, - serviceName, showErrorIcon, + span, viewEnd, viewStart, } = this.props; + const { duration, hasChildren: isParent, operationName, process: { serviceName } } = span; + const label = formatDuration(duration); const labelDetail = `${serviceName}::${operationName}`; let longLabel; @@ -119,9 +115,8 @@ export default class SpanBarRow extends React.PureComponent {
', () => { const spanID = 'some-id'; const props = { - spanID, className: 'a-class-name', color: 'color-a', columnDivision: '0.5', - depth: 3, isChildrenExpanded: true, isDetailExpanded: false, isFilteredOut: false, - isParent: true, - label: 'omg-awesome-label', onDetailToggled: jest.fn(), onChildrenToggled: jest.fn(), operationName: 'op-name', @@ -41,8 +40,15 @@ describe('', () => { operationName: 'rpc-op-name', serviceName: 'rpc-service-name', }, - serviceName: 'service-name', showErrorIcon: false, + span: { + duration: 'test-duration', + hasChildren: true, + process: { + serviceName: 'service-name', + }, + spanID, + }, viewEnd: 1, viewStart: 0, }; @@ -69,7 +75,7 @@ describe('', () => { it('escalates children toggling', () => { const { onChildrenToggled } = props; expect(onChildrenToggled.mock.calls.length).toBe(0); - wrapper.find('SpanTreeOffset').prop('onClick')(); + wrapper.find(SpanTreeOffset).prop('onClick')(); expect(onChildrenToggled.mock.calls).toEqual([[spanID]]); }); }); diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetailRow.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetailRow.js index 4a3954d06f..2c1e6583aa 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetailRow.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetailRow.js @@ -65,7 +65,7 @@ export default class SpanDetailRow extends React.PureComponent - + ', () => { const spanID = 'some-id'; const props = { @@ -61,7 +63,7 @@ describe('', () => { }); it('renders the span tree offset', () => { - const spanTreeOffset = ; + const spanTreeOffset = ; expect(wrapper.contains(spanTreeOffset)).toBe(true); }); diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.css b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.css index 81f526293c..1e17b4f520 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.css +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.css @@ -24,6 +24,32 @@ limitations under the License. cursor: pointer; } +.SpanTreeOffset--indentGuide { + /* The size of the indentGuide is based off of the iconWrapper */ + padding-right: calc(0.5rem + 12px); + height: 100%; + border-left: 1px solid transparent; + display: inline-flex; +} + +.SpanTreeOffset--indentGuide:before { + content: ''; + padding-left: 1px; + background-color: lightgrey; +} + +.SpanTreeOffset--indentGuide.is-active { + /* The size of the indentGuide is based off of the iconWrapper */ + padding-right: calc(0.5rem + 11px); + border-left: 0px; +} + +.SpanTreeOffset--indentGuide.is-active:before { + content: ''; + padding-left: 3px; + background-color: darkgrey; +} + .SpanTreeOffset--iconWrapper { position: absolute; right: 0.25rem; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.js index 1435e81db7..133c08b4a7 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.js @@ -14,33 +14,136 @@ // See the License for the specific language governing permissions and // limitations under the License. +import cx from 'classnames'; +import _get from 'lodash/get'; +import _find from 'lodash/find'; import React from 'react'; import IoChevronRight from 'react-icons/lib/io/chevron-right'; import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { actions } from './duck'; + +import type { ReduxState } from '../../../types/index'; +import type { Span } from '../../../types/trace'; import './SpanTreeOffset.css'; -type SpanTreeOffsetProps = { - level: number, - hasChildren: boolean, +type SpanTreeOffsetPropsType = { + addHoverIndentGuideId: string => void, childrenVisible: boolean, + hoverIndentGuideIds: Set, onClick: ?() => void, + removeHoverIndentGuideId: string => void, + span: Span, }; -export default function SpanTreeOffset(props: SpanTreeOffsetProps) { - const { level, hasChildren, childrenVisible, onClick } = props; - const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null; - const icon = hasChildren && (childrenVisible ? : ); - return ( - - - {icon && {icon}} - - ); +export class UnconnectedSpanTreeOffset extends React.PureComponent { + ancestorIds: string[]; + props: SpanTreeOffsetPropsType; + + static defaultProps = { + childrenVisible: false, + onClick: null, + }; + + constructor(props: SpanTreeOffsetPropsType) { + super(props); + + this.ancestorIds = []; + let currentSpan: Span = props.span; + while (currentSpan) { + currentSpan = _get(_find(currentSpan.references, { refType: 'CHILD_OF' }), 'span'); + if (currentSpan) { + this.ancestorIds.push(currentSpan.spanID); + } + } + + // Some traces have multiple root-level spans, this connects them all under one guideline and adds the + // necessary padding for the collapse icon on root-level spans. + this.ancestorIds.push('root'); + + this.ancestorIds.reverse(); + } + + /** + * If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is + * removed from the set of hoverIndentGuideIds. + * + * @param {Object} event - React Synthetic event tied to mouseleave. Includes the related target which is + * the element the user is now hovering. + * @param {string} ancestorId - The span id that the user was hovering over. + */ + handleMouseLeave = (event: SyntheticMouseEvent, ancestorId: string) => { + if ( + !(event.relatedTarget instanceof HTMLSpanElement) || + _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId + ) { + this.props.removeHoverIndentGuideId(ancestorId); + } + }; + + /** + * If the mouse entered this span from anywhere except another span with the same ancestor id, this span's + * ancestorId is added to the set of hoverIndentGuideIds. + * + * @param {Object} event - React Synthetic event tied to mouseenter. Includes the related target which is + * the last element the user was hovering. + * @param {string} ancestorId - The span id that the user is now hovering over. + */ + handleMouseEnter = ( + event: SyntheticMouseEvent & { relatedTarget?: { getAttribute: string => string } }, + ancestorId: string + ) => { + if ( + !(event.relatedTarget instanceof HTMLSpanElement) || + _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId + ) { + this.props.addHoverIndentGuideId(ancestorId); + } + }; + + render() { + const { childrenVisible, onClick, span } = this.props; + const { hasChildren, spanID } = span; + const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null; + const icon = hasChildren && (childrenVisible ? : ); + return ( + + {this.ancestorIds.map(ancestorId => ( + this.handleMouseEnter(event, ancestorId)} + onMouseLeave={event => this.handleMouseLeave(event, ancestorId)} + /> + ))} + {icon && ( + this.handleMouseEnter(event, spanID)} + onMouseLeave={event => this.handleMouseLeave(event, spanID)} + > + {icon} + + )} + + ); + } } -SpanTreeOffset.defaultProps = { - hasChildren: false, - childrenVisible: false, - onClick: null, -}; +export function mapStateToProps(state: ReduxState): { hoverIndentGuideIds: Set } { + const hoverIndentGuideIds = state.traceTimeline.hoverIndentGuideIds; + return { hoverIndentGuideIds }; +} + +export function mapDispatchToProps(dispatch: Function) { + const { addHoverIndentGuideId, removeHoverIndentGuideId } = bindActionCreators(actions, dispatch); + return { addHoverIndentGuideId, removeHoverIndentGuideId }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(UnconnectedSpanTreeOffset); diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.test.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.test.js new file mode 100644 index 0000000000..d42d8aada3 --- /dev/null +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanTreeOffset.test.js @@ -0,0 +1,186 @@ +// Copyright (c) 2018 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { shallow } from 'enzyme'; +import React from 'react'; +import IoChevronRight from 'react-icons/lib/io/chevron-right'; +import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down'; + +import { mapDispatchToProps, mapStateToProps, UnconnectedSpanTreeOffset } from './SpanTreeOffset'; + +describe('SpanTreeOffset', () => { + const ownSpanID = 'ownSpanID'; + const parentSpanID = 'parentSpanID'; + const rootSpanID = 'rootSpanID'; + const spanWithTwoAncestors = { + hasChildren: false, + references: [ + { + refType: 'CHILD_OF', + span: { + spanID: parentSpanID, + references: [ + { + refType: 'CHILD_OF', + span: { + spanID: rootSpanID, + }, + }, + ], + }, + }, + ], + spanID: ownSpanID, + }; + const specialRootID = 'root'; + let props; + let wrapper; + + beforeEach(() => { + props = { + addHoverIndentGuideId: jest.fn(), + hoverIndentGuideIds: new Set(), + removeHoverIndentGuideId: jest.fn(), + span: spanWithTwoAncestors, + }; + wrapper = shallow(); + }); + + describe('.SpanTreeOffset--indentGuide', () => { + it('renders only one .SpanTreeOffset--indentGuide for entire trace if span has no ancestors', () => { + props.span = { + spanID: 'parentlessSpanID', + references: [ + { + refType: 'NOT_CHILD_OF', + span: { + spanID: 'notAParentSpanID', + references: [], + }, + }, + ], + }; + wrapper = shallow(); + const indentGuides = wrapper.find('.SpanTreeOffset--indentGuide'); + expect(indentGuides.length).toBe(1); + expect(indentGuides.prop('data-ancestor-id')).toBe(specialRootID); + }); + + it('renders one .SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace', () => { + const indentGuides = wrapper.find('.SpanTreeOffset--indentGuide'); + expect(indentGuides.length).toBe(3); + expect(indentGuides.at(0).prop('data-ancestor-id')).toBe(specialRootID); + expect(indentGuides.at(1).prop('data-ancestor-id')).toBe(rootSpanID); + expect(indentGuides.at(2).prop('data-ancestor-id')).toBe(parentSpanID); + }); + + it('adds .is-active to correct indentGuide', () => { + props.hoverIndentGuideIds = new Set([parentSpanID]); + wrapper = shallow(); + const activeIndentGuide = wrapper.find('.is-active'); + expect(activeIndentGuide.length).toBe(1); + expect(activeIndentGuide.prop('data-ancestor-id')).toBe(parentSpanID); + }); + + it('calls props.addHoverIndentGuideId on mouse enter', () => { + wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseenter', {}); + expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1); + expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID); + }); + + it('does not call props.addHoverIndentGuideId on mouse enter if mouse came from a indentGuide with the same ancestorId', () => { + const relatedTarget = document.createElement('span'); + relatedTarget.dataset = { ancestorId: parentSpanID }; + wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseenter', { + relatedTarget, + }); + expect(props.addHoverIndentGuideId).not.toHaveBeenCalled(); + }); + + it('calls props.removeHoverIndentGuideId on mouse leave', () => { + wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseleave', {}); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID); + }); + + it('does not call props.removeHoverIndentGuideId on mouse leave if mouse leaves to a indentGuide with the same ancestorId', () => { + const relatedTarget = document.createElement('span'); + relatedTarget.dataset = { ancestorId: parentSpanID }; + wrapper.find({ 'data-ancestor-id': parentSpanID }).simulate('mouseleave', { + relatedTarget, + }); + expect(props.removeHoverIndentGuideId).not.toHaveBeenCalled(); + }); + }); + + describe('icon', () => { + beforeEach(() => { + wrapper.setProps({ span: { ...props.span, hasChildren: true } }); + }); + + it('does not render icon if props.span.hasChildren is false', () => { + wrapper.setProps({ span: { ...props.span, hasChildren: false } }); + expect(wrapper.find(IoChevronRight).length).toBe(0); + expect(wrapper.find(IoIosArrowDown).length).toBe(0); + }); + + it('renders IoChevronRight if props.span.hasChildren is true and props.childrenVisible is false', () => { + expect(wrapper.find(IoChevronRight).length).toBe(1); + expect(wrapper.find(IoIosArrowDown).length).toBe(0); + }); + + it('renders IoIosArrowDown if props.span.hasChildren is true and props.childrenVisible is true', () => { + wrapper.setProps({ childrenVisible: true }); + expect(wrapper.find(IoChevronRight).length).toBe(0); + expect(wrapper.find(IoIosArrowDown).length).toBe(1); + }); + + it('calls props.addHoverIndentGuideId on mouse enter', () => { + wrapper.find('.SpanTreeOffset--iconWrapper').simulate('mouseenter', {}); + expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1); + expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(ownSpanID); + }); + + it('calls props.removeHoverIndentGuideId on mouse leave', () => { + wrapper.find('.SpanTreeOffset--iconWrapper').simulate('mouseleave', {}); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1); + expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(ownSpanID); + }); + }); + + describe('mapDispatchToProps()', () => { + it('creates the actions correctly', () => { + expect(mapDispatchToProps(() => {})).toEqual({ + addHoverIndentGuideId: expect.any(Function), + removeHoverIndentGuideId: expect.any(Function), + }); + }); + }); + + describe('mapStateToProps()', () => { + it('maps state to props correctly', () => { + const hoverIndentGuideIds = new Set([parentSpanID]); + const state = { + traceTimeline: { + hoverIndentGuideIds, + }, + }; + const mappedProps = mapStateToProps(state); + expect(mappedProps).toEqual({ + hoverIndentGuideIds, + }); + expect(mappedProps.hoverIndentGuideIds).toBe(hoverIndentGuideIds); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js index 026b7f3706..bd840a8f3c 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js @@ -24,13 +24,7 @@ import ListView from './ListView'; import SpanBarRow from './SpanBarRow'; import DetailState from './SpanDetail/DetailState'; import SpanDetailRow from './SpanDetailRow'; -import { - findServerChildSpan, - formatDuration, - getViewedBounds, - isErrorSpan, - spanContainsErredSpan, -} from './utils'; +import { findServerChildSpan, getViewedBounds, isErrorSpan, spanContainsErredSpan } from './utils'; import getLinks from '../../../model/link-patterns'; import type { Accessors } from '../ScrollManager'; import type { Log, Span, Trace, KeyValuePair } from '../../../types/trace'; @@ -315,20 +309,15 @@ export class VirtualizedTraceViewImpl extends React.PureComponent diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.test.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.test.js index a17aeb112e..1a1bc7c65b 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.test.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.test.js @@ -11,7 +11,6 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. - import React from 'react'; import { shallow, mount } from 'enzyme'; @@ -23,6 +22,8 @@ import { DEFAULT_HEIGHTS, VirtualizedTraceViewImpl } from './VirtualizedTraceVie import traceGenerator from '../../../demo/trace-generators'; import transformTraceData from '../../../model/transform-trace-data'; +jest.mock('./SpanTreeOffset'); + describe('', () => { let wrapper; let instance; @@ -292,19 +293,15 @@ describe('', () => { ) ).toBe(true); diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.js index 7ec8aa4d49..12b7d28e01 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.js @@ -35,26 +35,29 @@ import generateActionTypes from '../../../utils/generate-action-types'; export function newInitialState({ spanNameColumnWidth = null, traceID = null } = {}) { return { - traceID, - spanNameColumnWidth: spanNameColumnWidth || 0.25, childrenHiddenIDs: new Set(), detailStates: new Map(), + hoverIndentGuideIds: new Set(), + spanNameColumnWidth: spanNameColumnWidth || 0.25, + traceID, }; } export const actionTypes = generateActionTypes('@jaeger-ui/trace-timeline-viewer', [ - 'SET_TRACE', - 'SET_SPAN_NAME_COLUMN_WIDTH', + 'ADD_HOVER_INDENT_GUIDE_ID', 'CHILDREN_TOGGLE', - 'EXPAND_ALL', 'COLLAPSE_ALL', - 'EXPAND_ONE', 'COLLAPSE_ONE', 'DETAIL_TOGGLE', 'DETAIL_TAGS_TOGGLE', 'DETAIL_PROCESS_TOGGLE', 'DETAIL_LOGS_TOGGLE', 'DETAIL_LOG_ITEM_TOGGLE', + 'EXPAND_ALL', + 'EXPAND_ONE', + 'REMOVE_HOVER_INDENT_GUIDE_ID', + 'SET_SPAN_NAME_COLUMN_WIDTH', + 'SET_TRACE', ]); const fullActions = createActions({ @@ -70,6 +73,8 @@ const fullActions = createActions({ [actionTypes.DETAIL_PROCESS_TOGGLE]: spanID => ({ spanID }), [actionTypes.DETAIL_LOGS_TOGGLE]: spanID => ({ spanID }), [actionTypes.DETAIL_LOG_ITEM_TOGGLE]: (spanID, logItem) => ({ logItem, spanID }), + [actionTypes.ADD_HOVER_INDENT_GUIDE_ID]: spanID => ({ spanID }), + [actionTypes.REMOVE_HOVER_INDENT_GUIDE_ID]: spanID => ({ spanID }), }); export const actions = fullActions.jaegerUi.traceTimelineViewer; @@ -206,6 +211,22 @@ function detailLogItemToggle(state, { payload }) { return { ...state, detailStates }; } +function addHoverIndentGuideId(state, { payload }) { + const { spanID } = payload; + const newHoverIndentGuideIds = new Set(state.hoverIndentGuideIds); + newHoverIndentGuideIds.add(spanID); + + return { ...state, hoverIndentGuideIds: newHoverIndentGuideIds }; +} + +function removeHoverIndentGuideId(state, { payload }) { + const { spanID } = payload; + const newHoverIndentGuideIds = new Set(state.hoverIndentGuideIds); + newHoverIndentGuideIds.delete(spanID); + + return { ...state, hoverIndentGuideIds: newHoverIndentGuideIds }; +} + export default handleActions( { [actionTypes.SET_TRACE]: setTrace, @@ -220,6 +241,8 @@ export default handleActions( [actionTypes.DETAIL_PROCESS_TOGGLE]: detailProcessToggle, [actionTypes.DETAIL_LOGS_TOGGLE]: detailLogsToggle, [actionTypes.DETAIL_LOG_ITEM_TOGGLE]: detailLogItemToggle, + [actionTypes.ADD_HOVER_INDENT_GUIDE_ID]: addHoverIndentGuideId, + [actionTypes.REMOVE_HOVER_INDENT_GUIDE_ID]: removeHoverIndentGuideId, }, newInitialState() ); diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.test.js b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.test.js index f51c2ea568..97bbc70fd3 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.test.js +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/duck.test.js @@ -286,4 +286,45 @@ describe('TraceTimelineViewer/duck', () => { store.dispatch(actions.detailLogItemToggle(id, logItem)); expect(store.getState().detailStates.get(id)).toEqual(toggledDetail); }); + + describe('hoverIndentGuideIds', () => { + const existingSpanId = 'existingSpanId'; + const newSpanId = 'newSpanId'; + + it('the initial state has an empty set of hoverIndentGuideIds', () => { + const state = store.getState(); + expect(state.hoverIndentGuideIds).toEqual(new Set()); + }); + + it('adds a spanID to an initial state', () => { + const action = actions.addHoverIndentGuideId(newSpanId); + store.dispatch(action); + expect(store.getState().hoverIndentGuideIds).toEqual(new Set([newSpanId])); + }); + + it('adds a spanID to a populated state', () => { + store = createStore(reducer, { + hoverIndentGuideIds: new Set([existingSpanId]), + }); + const action = actions.addHoverIndentGuideId(newSpanId); + store.dispatch(action); + expect(store.getState().hoverIndentGuideIds).toEqual(new Set([existingSpanId, newSpanId])); + }); + + it('should not error when removing a spanID from an initial state', () => { + const action = actions.removeHoverIndentGuideId(newSpanId); + store.dispatch(action); + expect(store.getState().hoverIndentGuideIds).toEqual(new Set()); + }); + + it('remove a spanID from a populated state', () => { + const secondExistingSpanId = 'secondExistingSpanId'; + store = createStore(reducer, { + hoverIndentGuideIds: new Set([existingSpanId, secondExistingSpanId]), + }); + const action = actions.removeHoverIndentGuideId(existingSpanId); + store.dispatch(action); + expect(store.getState().hoverIndentGuideIds).toEqual(new Set([secondExistingSpanId])); + }); + }); }); diff --git a/packages/jaeger-ui/src/types/trace-timeline.js b/packages/jaeger-ui/src/types/trace-timeline.js index 94457a4b2a..51ae92b727 100644 --- a/packages/jaeger-ui/src/types/trace-timeline.js +++ b/packages/jaeger-ui/src/types/trace-timeline.js @@ -22,4 +22,5 @@ export type TraceTimeline = { childrenHiddenIDs: Set, findMatches: ?Set, detailStates: Map, + hoverIndentGuideIds: Set, };