1// Copyright (c) 2017 Uber Technologies, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import React from 'react';
16import { get as _get } from 'lodash';
17import IoChevronRight from 'react-icons/lib/io/chevron-right';
18import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
19import { css } from '@emotion/css';
20import cx from 'classnames';
21
22import { TraceSpan } from '../types/trace';
23import spanAncestorIds from '../utils/span-ancestor-ids';
24
25import { autoColor, createStyle, Theme, withTheme } from '../Theme';
26
27export const getStyles = createStyle((theme: Theme) => {
28  return {
29    SpanTreeOffset: css`
30      label: SpanTreeOffset;
31      color: ${autoColor(theme, '#000')};
32      position: relative;
33    `,
34    SpanTreeOffsetParent: css`
35      label: SpanTreeOffsetParent;
36      &:hover {
37        cursor: pointer;
38      }
39    `,
40    indentGuide: css`
41      label: indentGuide;
42      /* The size of the indentGuide is based off of the iconWrapper */
43      padding-right: calc(0.5rem + 12px);
44      height: 100%;
45      border-left: 3px solid transparent;
46      display: inline-flex;
47      &::before {
48        content: '';
49        padding-left: 1px;
50        background-color: ${autoColor(theme, 'lightgrey')};
51      }
52    `,
53    indentGuideActive: css`
54      label: indentGuideActive;
55      border-color: ${autoColor(theme, 'darkgrey')};
56      &::before {
57        background-color: transparent;
58      }
59    `,
60    iconWrapper: css`
61      label: iconWrapper;
62      position: absolute;
63      right: 0.25rem;
64    `,
65  };
66});
67
68type TProps = {
69  childrenVisible?: boolean;
70  onClick?: () => void;
71  span: TraceSpan;
72  showChildrenIcon?: boolean;
73
74  hoverIndentGuideIds: Set<string>;
75  addHoverIndentGuideId: (spanID: string) => void;
76  removeHoverIndentGuideId: (spanID: string) => void;
77  theme: Theme;
78};
79
80export class UnthemedSpanTreeOffset extends React.PureComponent<TProps> {
81  static displayName = 'UnthemedSpanTreeOffset';
82
83  ancestorIds: string[];
84
85  static defaultProps = {
86    childrenVisible: false,
87    showChildrenIcon: true,
88  };
89
90  constructor(props: TProps) {
91    super(props);
92
93    this.ancestorIds = spanAncestorIds(props.span);
94    // Some traces have multiple root-level spans, this connects them all under one guideline and adds the
95    // necessary padding for the collapse icon on root-level spans.
96    this.ancestorIds.push('root');
97
98    this.ancestorIds.reverse();
99  }
100
101  /**
102   * If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is
103   * removed from the set of hoverIndentGuideIds.
104   *
105   * @param {Object} event - React Synthetic event tied to mouseleave. Includes the related target which is
106   *     the element the user is now hovering.
107   * @param {string} ancestorId - The span id that the user was hovering over.
108   */
109  handleMouseLeave = (event: React.MouseEvent<HTMLSpanElement>, ancestorId: string) => {
110    if (
111      !(event.relatedTarget instanceof HTMLSpanElement) ||
112      _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId
113    ) {
114      this.props.removeHoverIndentGuideId(ancestorId);
115    }
116  };
117
118  /**
119   * If the mouse entered this span from anywhere except another span with the same ancestor id, this span's
120   * ancestorId is added to the set of hoverIndentGuideIds.
121   *
122   * @param {Object} event - React Synthetic event tied to mouseenter. Includes the related target which is
123   *     the last element the user was hovering.
124   * @param {string} ancestorId - The span id that the user is now hovering over.
125   */
126  handleMouseEnter = (event: React.MouseEvent<HTMLSpanElement>, ancestorId: string) => {
127    if (
128      !(event.relatedTarget instanceof HTMLSpanElement) ||
129      _get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId
130    ) {
131      this.props.addHoverIndentGuideId(ancestorId);
132    }
133  };
134
135  render() {
136    const { childrenVisible, onClick, showChildrenIcon, span, theme } = this.props;
137    const { hasChildren, spanID } = span;
138    const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null;
139    const icon = showChildrenIcon && hasChildren && (childrenVisible ? <IoIosArrowDown /> : <IoChevronRight />);
140    const styles = getStyles(theme);
141    return (
142      <span className={cx(styles.SpanTreeOffset, { [styles.SpanTreeOffsetParent]: hasChildren })} {...wrapperProps}>
143        {this.ancestorIds.map((ancestorId) => (
144          <span
145            key={ancestorId}
146            className={cx(styles.indentGuide, {
147              [styles.indentGuideActive]: this.props.hoverIndentGuideIds.has(ancestorId),
148            })}
149            data-ancestor-id={ancestorId}
150            data-test-id="SpanTreeOffset--indentGuide"
151            onMouseEnter={(event) => this.handleMouseEnter(event, ancestorId)}
152            onMouseLeave={(event) => this.handleMouseLeave(event, ancestorId)}
153          />
154        ))}
155        {icon && (
156          <span
157            className={styles.iconWrapper}
158            onMouseEnter={(event) => this.handleMouseEnter(event, spanID)}
159            onMouseLeave={(event) => this.handleMouseLeave(event, spanID)}
160            data-test-id="icon-wrapper"
161          >
162            {icon}
163          </span>
164        )}
165      </span>
166    );
167  }
168}
169
170export default withTheme(UnthemedSpanTreeOffset);
171