Skip to content

Commit

Permalink
Add indent guides to trace timeline view (#172) (#297)
Browse files Browse the repository at this point in the history
* Add indent guides to trace timeline view (#172)

Signed-off-by: Everett Ross <reverett@uber.com>

* Add tests for connect functions, add more flow types

Signed-off-by: Everett Ross <reverett@uber.com>

* Consolidate ducks, remove redudant PropTypes, add event type

Signed-off-by: Everett Ross <reverett@uber.com>

* Rename hoverSpanId to hoverIndentGuideId

Signed-off-by: Everett Ross <reverett@uber.com>

* Derive props from span, use dataset over getAttribute

Signed-off-by: Everett Ross <reverett@uber.com>
  • Loading branch information
everett980 authored and tiffon committed Jan 4, 2019
1 parent ac4f7a7 commit f23eb48
Show file tree
Hide file tree
Showing 12 changed files with 435 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -46,9 +45,8 @@ type SpanBarRowProps = {
operationName: string,
serviceName: string,
},
serviceName: string,
showErrorIcon: boolean,
spanID: string,
span: Span,
viewEnd: number,
viewStart: number,
};
Expand All @@ -70,32 +68,30 @@ export default class SpanBarRow extends React.PureComponent<SpanBarRowProps> {
};

_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() {
const {
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;
Expand All @@ -119,9 +115,8 @@ export default class SpanBarRow extends React.PureComponent<SpanBarRowProps> {
<TimelineRow.Cell className="span-name-column" width={columnDivision}>
<div className={`span-name-wrapper ${isMatchingFilter ? 'is-matching-filter' : ''}`}>
<SpanTreeOffset
level={depth + 1}
hasChildren={isParent}
childrenVisible={isChildrenExpanded}
span={span}
onClick={isParent ? this._childrenToggle : null}
/>
<a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,19 @@ import React from 'react';
import { mount } from 'enzyme';

import SpanBarRow from './SpanBarRow';
import SpanTreeOffset from './SpanTreeOffset';

jest.mock('./SpanTreeOffset');

describe('<SpanBarRow>', () => {
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',
Expand All @@ -41,8 +40,15 @@ describe('<SpanBarRow>', () => {
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,
};
Expand All @@ -69,7 +75,7 @@ describe('<SpanBarRow>', () => {
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]]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default class SpanDetailRow extends React.PureComponent<SpanDetailRowProp
return (
<TimelineRow className="detail-row">
<TimelineRow.Cell width={columnDivision}>
<SpanTreeOffset level={span.depth + 1} />
<SpanTreeOffset span={span} />
<span>
<span
className="detail-row-expanded-accent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import SpanDetail from './SpanDetail';
import DetailState from './SpanDetail/DetailState';
import SpanTreeOffset from './SpanTreeOffset';

jest.mock('./SpanTreeOffset');

describe('<SpanDetailRow>', () => {
const spanID = 'some-id';
const props = {
Expand Down Expand Up @@ -61,7 +63,7 @@ describe('<SpanDetailRow>', () => {
});

it('renders the span tree offset', () => {
const spanTreeOffset = <SpanTreeOffset level={props.span.depth + 1} />;
const spanTreeOffset = <SpanTreeOffset span={props.span} />;
expect(wrapper.contains(spanTreeOffset)).toBe(true);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
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 ? <IoIosArrowDown /> : <IoChevronRight />);
return (
<span className={`SpanTreeOffset ${hasChildren ? 'is-parent' : ''}`} {...wrapperProps}>
<span style={{ paddingLeft: `${level * 20}px` }} />
{icon && <span className="SpanTreeOffset--iconWrapper">{icon}</span>}
</span>
);
export class UnconnectedSpanTreeOffset extends React.PureComponent<SpanTreeOffsetPropsType> {
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<HTMLSpanElement>, 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<HTMLSpanElement> & { 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 ? <IoIosArrowDown /> : <IoChevronRight />);
return (
<span className={`SpanTreeOffset ${hasChildren ? 'is-parent' : ''}`} {...wrapperProps}>
{this.ancestorIds.map(ancestorId => (
<span
key={ancestorId}
className={cx('SpanTreeOffset--indentGuide', {
'is-active': this.props.hoverIndentGuideIds.has(ancestorId),
})}
data-ancestor-id={ancestorId}
onMouseEnter={event => this.handleMouseEnter(event, ancestorId)}
onMouseLeave={event => this.handleMouseLeave(event, ancestorId)}
/>
))}
{icon && (
<span
className="SpanTreeOffset--iconWrapper"
onMouseEnter={event => this.handleMouseEnter(event, spanID)}
onMouseLeave={event => this.handleMouseLeave(event, spanID)}
>
{icon}
</span>
)}
</span>
);
}
}

SpanTreeOffset.defaultProps = {
hasChildren: false,
childrenVisible: false,
onClick: null,
};
export function mapStateToProps(state: ReduxState): { hoverIndentGuideIds: Set<string> } {
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);
Loading

0 comments on commit f23eb48

Please sign in to comment.