Skip to content

Commit

Permalink
feat(frontend): add vertical resizer in timeline view (#3670)
Browse files Browse the repository at this point in the history
  • Loading branch information
jorgeepc committed Feb 23, 2024
1 parent 24d538e commit 78a2ecd
Show file tree
Hide file tree
Showing 13 changed files with 372 additions and 32 deletions.
Expand Up @@ -3,6 +3,7 @@ import Connector from './Connector';
import {IPropsComponent} from '../SpanNodeFactory';
import {useTimeline} from '../Timeline.provider';
import * as S from '../Timeline.styled';
import {useVerticalResizer} from '../VerticalResizer.provider';

function toPercent(value: number) {
return `${(value * 100).toFixed(1)}%`;
Expand All @@ -18,6 +19,7 @@ interface IProps extends IPropsComponent {

const BaseSpanNode = ({index, node, span, style}: IProps) => {
const {collapsedSpans, getScale, matchedSpans, onSpanCollapse, onSpanClick, selectedSpan} = useTimeline();
const {columnWidth} = useVerticalResizer();
const {start: viewStart, end: viewEnd} = getScale(span.startTime, span.endTime);
const hintSide = getHintSide(viewStart, viewEnd);
const isSelected = selectedSpan === node.data.id;
Expand All @@ -31,6 +33,7 @@ const BaseSpanNode = ({index, node, span, style}: IProps) => {
$isEven={index % 2 === 0}
$isMatched={isMatched}
$isSelected={isSelected}
style={{gridTemplateColumns: `${columnWidth * 100}% 1fr`}}
>
<S.Col>
<S.Header>
Expand All @@ -46,7 +49,6 @@ const BaseSpanNode = ({index, node, span, style}: IProps) => {
<S.Title>{span.name}</S.Title>
</S.NameContainer>
</S.Header>
<S.Separator />
</S.Col>

<S.ColDuration>
Expand Down
Expand Up @@ -47,7 +47,16 @@ const Connector = ({hasParent, id, isCollapsed, nodeDepth, onCollapse, totalChil
)}

{new Array(nodeDepth).fill(0).map((_, index) => {
return <S.LineBase x1={index * BaseLeftPaddingV2 + 12} x2={index * BaseLeftPaddingV2 + 12} y1="0" y2="32" />;
return (
<S.LineBase
// eslint-disable-next-line react/no-array-index-key
key={index * BaseLeftPaddingV2}
x1={index * BaseLeftPaddingV2 + 12}
x2={index * BaseLeftPaddingV2 + 12}
y1="0"
y2="32"
/>
);
})}
</S.Connector>
);
Expand Down
30 changes: 19 additions & 11 deletions web/src/components/Visualization/components/Timeline/Header.tsx
@@ -1,22 +1,30 @@
import {toPercent} from 'utils/Common';
import Ticks from './Ticks/Ticks';
import * as S from './Timeline.styled';
import VerticalResizer from './VerticalResizer/VerticalResizer';
import {useVerticalResizer} from './VerticalResizer.provider';

const NUM_TICKS = 5;

interface IProps {
duration: number;
}

const Header = ({duration}: IProps) => (
<S.HeaderRow>
<S.Col>
<S.HeaderContent>
<S.HeaderTitle level={3}>Span</S.HeaderTitle>
</S.HeaderContent>
<S.Separator />
</S.Col>
<Ticks numTicks={NUM_TICKS} startTime={0} endTime={duration} />
</S.HeaderRow>
);
const Header = ({duration}: IProps) => {
const {columnWidth} = useVerticalResizer();

return (
<S.HeaderRow style={{gridTemplateColumns: `${toPercent(columnWidth)} 1fr`}}>
<S.Col>
<S.HeaderContent>
<S.HeaderTitle level={3}>Span</S.HeaderTitle>
</S.HeaderContent>
</S.Col>
<Ticks numTicks={NUM_TICKS} startTime={0} endTime={duration} />

<VerticalResizer />
</S.HeaderRow>
);
};

export default Header;
Expand Up @@ -2,6 +2,7 @@ import {Typography} from 'antd';
import styled, {css} from 'styled-components';

export const Ticks = styled.div`
padding-right: 16px;
pointer-events: none;
position: relative;
`;
Expand Down
Expand Up @@ -10,9 +10,7 @@ export const Container = styled.div`
export const Row = styled.div<{$isEven: boolean; $isMatched: boolean; $isSelected: boolean}>`
background-color: ${({theme, $isEven}) => ($isEven ? theme.color.background : theme.color.white)};
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 32px;
padding: 0px 16px;
:hover {
background-color: ${({theme}) => theme.color.backgroundInteractive};
Expand All @@ -37,11 +35,14 @@ export const Row = styled.div<{$isEven: boolean; $isMatched: boolean; $isSelecte

export const Col = styled.div`
display: grid;
grid-template-columns: 1fr 8px;
grid-template-columns: 1fr;
padding-left: 16px;
`;

export const ColDuration = styled.div`
overflow: hidden;
margin-left: 15px;
padding-right: 16px;
position: relative;
`;

Expand All @@ -58,14 +59,6 @@ export const NameContainer = styled.div`
text-overflow: ellipsis;
`;

export const Separator = styled.div`
border-left: 1px solid rgb(222, 227, 236);
cursor: ew-resize;
height: 32px;
padding: 0px 3px;
width: 1px;
`;

export const Title = styled(Typography.Text)`
color: ${({theme}) => theme.color.text};
font-size: ${({theme}) => theme.size.sm};
Expand Down Expand Up @@ -131,9 +124,8 @@ export const RectBaseTransparent = styled(RectBase)`
export const HeaderRow = styled.div`
background-color: ${({theme}) => theme.color.white};
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: 32px;
padding: 0px 16px;
position: relative;
`;

export const HeaderContent = styled.div`
Expand Down
Expand Up @@ -2,9 +2,10 @@ import {NodeTypesEnum} from 'constants/Visualization.constants';
import Span from 'models/Span.model';
import {useRef} from 'react';
import {FixedSizeList as List} from 'react-window';
import ListWrapper from './ListWrapper';
import NavigationWrapper from './NavigationWrapper';
import TimelineProvider from './Timeline.provider';
import ListWrapper from './ListWrapper';
import VerticalResizerProvider from './VerticalResizer.provider';

export interface IProps {
nodeType: NodeTypesEnum;
Expand All @@ -28,8 +29,10 @@ const Timeline = ({nodeType, spans, onClick, onNavigate, matchedSpans, selectedS
nodeType={nodeType}
spans={spans}
>
<NavigationWrapper />
<ListWrapper listRef={listRef} />
<VerticalResizerProvider>
<NavigationWrapper />
<ListWrapper listRef={listRef} />
</VerticalResizerProvider>
</TimelineProvider>
);
};
Expand Down
@@ -0,0 +1,67 @@
import {INITIAL_NAME_COLUMN_WIDTH, MAX_NAME_COLUMN_WIDTH, MIN_NAME_COLUMN_WIDTH} from 'constants/Timeline.constants';
import {createContext, useCallback, useContext, useMemo, useState} from 'react';
import DraggableManager from 'utils/DragabbleManager';

interface IContext {
columnWidth: number;
highlightPosition: number | null;
initResizer({rootRef}: {rootRef: React.RefObject<HTMLDivElement>}): ReturnType<typeof DraggableManager>;
}

export const Context = createContext<IContext>({
columnWidth: 0,
highlightPosition: null,
initResizer: () => DraggableManager({onGetBounds: () => ({clientXLeft: 0, width: 0})}),
});

interface IProps {
children: React.ReactNode;
}

export const useVerticalResizer = () => useContext(Context);

const VerticalResizerProvider = ({children}: IProps) => {
const [columnWidth, setColumnWidth] = useState(INITIAL_NAME_COLUMN_WIDTH);
const [highlightPosition, setHighlightPosition] = useState<number | null>(null);

const initResizer = useCallback(({rootRef}: {rootRef: React.RefObject<HTMLDivElement>}) => {
const draggableManager = DraggableManager({
onGetBounds: () => {
if (!rootRef.current) {
return {clientXLeft: 0, width: 0, maxValue: 0, minValue: 0};
}

const {left: clientXLeft, width} = rootRef.current.getBoundingClientRect();
return {
clientXLeft,
width,
maxValue: MAX_NAME_COLUMN_WIDTH,
minValue: MIN_NAME_COLUMN_WIDTH,
};
},
onDragEnd: ({value, resetBounds}) => {
resetBounds();
setHighlightPosition(null);
setColumnWidth(value);
},
onDragMove: ({value}) => {
setHighlightPosition(value);
},
});

return draggableManager;
}, []);

const value = useMemo<IContext>(
() => ({
columnWidth,
highlightPosition,
initResizer,
}),
[columnWidth, highlightPosition, initResizer]
);

return <Context.Provider value={value}>{children}</Context.Provider>;
};

export default VerticalResizerProvider;
@@ -0,0 +1,53 @@
import styled from 'styled-components';

export const VerticalResizer = styled.div`
left: 0;
position: absolute;
right: 0;
top: 0;
`;

export const VerticalResizerDragger = styled.div`
border-left: 1px solid rgb(222, 227, 236);
cursor: ew-resize;
height: calc(100vh - 50px);
margin-left: -1px;
position: absolute;
top: 0;
width: 1px;
z-index: 2;
::before {
position: absolute;
top: 0;
bottom: 0;
left: -8px;
right: 0;
content: ' ';
}
:hover {
border-left: 2px solid ${({theme}) => theme.color.border};
}
&.right-dragging,
&.left-dragging {
background: rgba(136, 0, 136, 0.05);
width: unset;
::before {
left: -2000px;
right: -2000px;
}
}
&.left-dragging {
border-left: 2px solid ${({theme}) => theme.color.primaryLight};
border-right: 1px solid ${({theme}) => theme.color.border};
}
&.right-dragging {
border-left: 1px solid ${({theme}) => theme.color.border};
border-right: 2px solid ${({theme}) => theme.color.primaryLight};
}
`;
@@ -0,0 +1,44 @@
import {useEffect, useRef} from 'react';
import {toPercent} from 'utils/Common';
import * as S from './VerticalResizer.styled';
import {useVerticalResizer} from '../VerticalResizer.provider';

const VerticalResizer = () => {
const {columnWidth, highlightPosition, initResizer} = useVerticalResizer();
const rootRef = useRef<HTMLDivElement>(null);
const draggerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const resizer = initResizer({rootRef});
const handleMouseDown = resizer.initEventHandler();
draggerRef.current?.addEventListener('mousedown', handleMouseDown);

return () => {
resizer.cleanup();
draggerRef.current?.removeEventListener('mousedown', handleMouseDown);

Check warning on line 18 in web/src/components/Visualization/components/Timeline/VerticalResizer/VerticalResizer.tsx

View workflow job for this annotation

GitHub Actions / WebUI unit tests

The ref value 'draggerRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'draggerRef.current' to a variable inside the effect, and use that variable in the cleanup function
};
}, [initResizer]);

let draggerClass = '';
let draggerStyle;

if (highlightPosition !== null) {
draggerClass = `${highlightPosition > columnWidth ? 'right' : 'left'}-dragging`;
// Draw a highlight from the current dragged position to the original position
const draggerLeft = toPercent(Math.min(columnWidth, highlightPosition));
// Subtract 1px for draggerRight to deal with the right border being off
// by 1px when dragging left
const draggerRight = `calc(${toPercent(1 - Math.max(columnWidth, highlightPosition))} - 1px)`;
draggerStyle = {left: draggerLeft, right: draggerRight};
} else {
draggerStyle = {left: toPercent(columnWidth)};
}

return (
<S.VerticalResizer ref={rootRef}>
<S.VerticalResizerDragger className={draggerClass} style={draggerStyle} ref={draggerRef} />
</S.VerticalResizer>
);
};

export default VerticalResizer;
3 changes: 3 additions & 0 deletions web/src/constants/Timeline.constants.ts
Expand Up @@ -4,3 +4,6 @@ export const NodeHeight = 66;
export const NodeOverlayHeight = NodeHeight - 2;
export const BaseLeftPadding = 10;
export const BaseLeftPaddingV2 = 16;
export const MIN_NAME_COLUMN_WIDTH = 0.15;
export const MAX_NAME_COLUMN_WIDTH = 0.65;
export const INITIAL_NAME_COLUMN_WIDTH = 0.3;
6 changes: 3 additions & 3 deletions web/src/services/TestRun.service.ts
Expand Up @@ -102,9 +102,9 @@ const TestRunService = () => ({
getHeaderInfo({createdAt, testVersion, metadata: {source = ''}, trace}: TestRun, triggerType: string) {
const createdTimeAgo = Date.getTimeAgo(createdAt ?? '');

return `v${testVersion}${triggerType} • Ran ${createdTimeAgo}${
!!trace?.spans.length && `${trace.spans.length} ${singularOrPlural('span', trace?.spans.length)}`
} ${source && `• Run via ${source.toUpperCase()}`}`;
return `v${testVersion}${triggerType} • Ran ${createdTimeAgo}${`${
trace?.spans?.length ?? 0
} ${singularOrPlural('span', trace?.spans.length)}`} ${source && `• Run via ${source.toUpperCase()}`}`;
},
});

Expand Down
4 changes: 4 additions & 0 deletions web/src/utils/Common.ts
Expand Up @@ -96,3 +96,7 @@ export const withLowPriority =
resolve(fn(...args));
}, 0);
});

export const toPercent = (value: number) => {
return `${value * 100}%`;
};

0 comments on commit 78a2ecd

Please sign in to comment.