grafana/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx

184 lines
6.0 KiB
TypeScript

// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import { get as _get } from 'lodash';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, stylesFactory, withTheme2 } from '@grafana/ui';
import { autoColor } from '../Theme';
import { TraceSpan } from '../types/trace';
import spanAncestorIds from '../utils/span-ancestor-ids';
export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
SpanTreeOffset: css({
label: 'SpanTreeOffset',
color: autoColor(theme, '#000'),
position: 'relative',
}),
SpanTreeOffsetParent: css({
label: 'SpanTreeOffsetParent',
'&:hover': {
cursor: 'pointer',
},
}),
indentGuide: css({
label: 'indentGuide',
/* The size of the indentGuide is based off of the iconWrapper */
paddingRight: '1rem',
height: '100%',
display: 'inline-flex',
[theme.transitions.handleMotion('no-preference')]: {
transition: 'padding 300ms ease-out',
},
'&::before': {
content: '""',
paddingLeft: '1px',
backgroundColor: autoColor(theme, 'lightgrey'),
},
}),
indentGuideActive: css({
label: 'indentGuideActive',
'&::before': {
backgroundColor: autoColor(theme, '#777'),
},
}),
indentGuideThin: css({
paddingRight: '0.3rem',
}),
iconWrapper: css({
label: 'iconWrapper',
position: 'absolute',
right: 0,
}),
}));
export type TProps = {
childrenVisible?: boolean;
onClick?: () => void;
span: TraceSpan;
showChildrenIcon?: boolean;
hoverIndentGuideIds: Set<string>;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: GrafanaTheme2;
visibleSpanIds: string[];
};
export class UnthemedSpanTreeOffset extends React.PureComponent<TProps> {
static displayName = 'UnthemedSpanTreeOffset';
ancestorIds: string[];
static defaultProps = {
childrenVisible: false,
showChildrenIcon: true,
};
constructor(props: TProps) {
super(props);
this.ancestorIds = spanAncestorIds(props.span);
// 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: React.MouseEvent<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: React.MouseEvent<HTMLSpanElement>, ancestorId: string) => {
if (
!(event.relatedTarget instanceof HTMLSpanElement) ||
_get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId
) {
this.props.addHoverIndentGuideId(ancestorId);
}
};
render() {
const { childrenVisible, onClick, showChildrenIcon, span, theme, visibleSpanIds } = this.props;
const { hasChildren, spanID } = span;
const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null;
const icon =
showChildrenIcon &&
hasChildren &&
(childrenVisible ? (
<Icon name={'angle-down'} data-testid="icon-arrow-down" size={'sm'} />
) : (
<Icon name={'angle-right'} data-testid="icon-arrow-right" size={'sm'} />
));
const styles = getStyles(theme);
return (
<span className={cx(styles.SpanTreeOffset, { [styles.SpanTreeOffsetParent]: hasChildren })} {...wrapperProps}>
{this.ancestorIds.map((ancestorId, index) => (
<span
key={ancestorId}
className={cx(styles.indentGuide, {
[styles.indentGuideActive]: this.props.hoverIndentGuideIds.has(ancestorId),
[styles.indentGuideThin]:
index !== this.ancestorIds.length - 1 && ancestorId !== 'root' && !visibleSpanIds.includes(ancestorId),
})}
data-ancestor-id={ancestorId}
data-testid="SpanTreeOffset--indentGuide"
onMouseEnter={(event) => this.handleMouseEnter(event, ancestorId)}
onMouseLeave={(event) => this.handleMouseLeave(event, ancestorId)}
/>
))}
{icon && (
<span
className={styles.iconWrapper}
onMouseEnter={(event) => this.handleMouseEnter(event, spanID)}
onMouseLeave={(event) => this.handleMouseLeave(event, spanID)}
data-testid="icon-wrapper"
>
{icon}
</span>
)}
</span>
);
}
}
export default withTheme2(UnthemedSpanTreeOffset);