Skip to content

Commit a8704e8

Browse files
feat: Trace exploration improvements (#537)
* Listen to changes for Traces Drilldown button in embedded mode * Open trace in smart drawer * Update subscription * Update listening * Remove spaces * Drawer * Spellcheck --------- Co-authored-by: Andre Pereira <adrapereira@gmail.com>
1 parent 7cf574e commit a8704e8

4 files changed

Lines changed: 380 additions & 16 deletions

File tree

cspell.config.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@
6262
"zizmor",
6363
"Zizmor",
6464
"occurrences",
65-
"sparkline"
65+
"sparkline",
66+
"behaviour",
67+
"Resizeble",
68+
"Dismissable"
6669
]
6770
}

src/pages/Explore/Drawer.tsx

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
import { css, cx } from '@emotion/css';
2+
import { useDialog } from '@react-aria/dialog';
3+
import { FocusScope } from '@react-aria/focus';
4+
import { useOverlay } from '@react-aria/overlays';
5+
import RcDrawer from 'rc-drawer';
6+
import { ReactNode, useCallback, useEffect, useState } from 'react';
7+
import * as React from 'react';
8+
9+
import { GrafanaTheme2 } from '@grafana/data';
10+
import { selectors } from '@grafana/e2e-selectors';
11+
import { t } from '@grafana/i18n';
12+
13+
import 'rc-drawer/assets/index.css';
14+
import { useStyles2, getDragStyles, IconButton, ScrollContainer, Text } from '@grafana/ui';
15+
16+
export interface Props {
17+
children: ReactNode;
18+
/** Title shown at the top of the drawer */
19+
title?: ReactNode;
20+
/** Subtitle shown below the title */
21+
subtitle?: ReactNode;
22+
/** Should the Drawer be closable by clicking on the mask, defaults to true */
23+
closeOnMaskClick?: boolean;
24+
/**
25+
* Specifies the width and min-width.
26+
* sm = width 25% & min-width 384px
27+
* md = width 50% & min-width 568px
28+
* lg = width 75% & min-width 744px
29+
**/
30+
size?: 'sm' | 'md' | 'lg';
31+
/** Tabs */
32+
tabs?: React.ReactNode;
33+
/**
34+
* Whether the content should be wrapped in a ScrollContainer
35+
* Only change this if you intend to manage scroll behaviour yourself
36+
* (e.g. having a split pane with independent scrolling)
37+
**/
38+
scrollableContent?: boolean;
39+
/** Callback for closing the drawer */
40+
onClose: () => void;
41+
}
42+
43+
const drawerSizes = {
44+
sm: { width: '25%', minWidth: 384 },
45+
md: { width: '50%', minWidth: 568 },
46+
lg: { width: '75%', minWidth: 744 },
47+
};
48+
49+
export function Drawer({
50+
children,
51+
onClose,
52+
closeOnMaskClick = true,
53+
scrollableContent = true,
54+
title,
55+
subtitle,
56+
size = 'md',
57+
tabs,
58+
}: Props) {
59+
const [drawerWidth, onMouseDown, onTouchStart] = useResizebleDrawer();
60+
61+
const styles = useStyles2(getStyles);
62+
const wrapperStyles = useStyles2(getWrapperStyles, size);
63+
const dragStyles = useStyles2(getDragStyles);
64+
65+
const overlayRef = React.useRef(null);
66+
const { dialogProps, titleProps } = useDialog({}, overlayRef);
67+
const { overlayProps } = useOverlay(
68+
{
69+
isDismissable: false,
70+
isOpen: true,
71+
onClose,
72+
},
73+
overlayRef
74+
);
75+
76+
// Adds body class while open so the toolbar nav can hide some actions while drawer is open
77+
useBodyClassWhileOpen();
78+
79+
const content = <div className={styles.content}>{children}</div>;
80+
const overrideWidth = drawerWidth ?? drawerSizes[size].width;
81+
const minWidth = drawerSizes[size].minWidth;
82+
83+
return (
84+
<RcDrawer
85+
open={true}
86+
onClose={onClose}
87+
placement="right"
88+
getContainer={'#trace-exploration'}
89+
className={styles.drawerContent}
90+
rootClassName={styles.drawer}
91+
classNames={{
92+
wrapper: wrapperStyles,
93+
}}
94+
styles={{
95+
wrapper: {
96+
width: overrideWidth,
97+
minWidth,
98+
},
99+
}}
100+
width={''}
101+
motion={{
102+
motionAppear: true,
103+
motionName: styles.drawerMotion,
104+
}}
105+
maskClassName={styles.mask}
106+
maskClosable={closeOnMaskClick}
107+
maskMotion={{
108+
motionAppear: true,
109+
motionName: styles.maskMotion,
110+
}}
111+
>
112+
<FocusScope restoreFocus contain autoFocus>
113+
<div
114+
aria-label={
115+
typeof title === 'string'
116+
? selectors.components.Drawer.General.title(title)
117+
: selectors.components.Drawer.General.title('no title')
118+
}
119+
className={styles.container}
120+
{...overlayProps}
121+
{...dialogProps}
122+
ref={overlayRef}
123+
>
124+
<div
125+
className={cx(dragStyles.dragHandleVertical, styles.resizer)}
126+
onMouseDown={onMouseDown}
127+
onTouchStart={onTouchStart}
128+
/>
129+
<div className={cx(styles.header, Boolean(tabs) && styles.headerWithTabs)}>
130+
<div className={styles.actions}>
131+
<IconButton
132+
name="times"
133+
variant="secondary"
134+
onClick={onClose}
135+
data-testid={selectors.components.Drawer.General.close}
136+
tooltip={t(`grafana-ui.drawer.close`, 'Close')}
137+
/>
138+
</div>
139+
{typeof title === 'string' ? (
140+
<div className={styles.titleWrapper}>
141+
<Text element="h3" {...titleProps}>
142+
{title}
143+
</Text>
144+
{subtitle && (
145+
<div className={styles.subtitle} data-testid={selectors.components.Drawer.General.subtitle}>
146+
{subtitle}
147+
</div>
148+
)}
149+
</div>
150+
) : (
151+
title
152+
)}
153+
{tabs && <div className={styles.tabsWrapper}>{tabs}</div>}
154+
</div>
155+
{!scrollableContent ? content : <ScrollContainer showScrollIndicators>{content}</ScrollContainer>}
156+
</div>
157+
</FocusScope>
158+
</RcDrawer>
159+
);
160+
}
161+
162+
function useResizebleDrawer(): [
163+
string | undefined,
164+
React.EventHandler<React.MouseEvent>,
165+
React.EventHandler<React.TouchEvent>,
166+
] {
167+
const [drawerWidth, setDrawerWidth] = useState<string | undefined>(undefined);
168+
169+
const onMouseMove = useCallback((e: MouseEvent) => {
170+
setDrawerWidth(getCustomDrawerWidth(e.clientX));
171+
}, []);
172+
173+
const onTouchMove = useCallback((e: TouchEvent) => {
174+
const touch = e.touches[0];
175+
setDrawerWidth(getCustomDrawerWidth(touch.clientX));
176+
}, []);
177+
178+
const onMouseUp = useCallback(
179+
(e: MouseEvent) => {
180+
document.removeEventListener('mousemove', onMouseMove);
181+
document.removeEventListener('mouseup', onMouseUp);
182+
},
183+
[onMouseMove]
184+
);
185+
186+
const onTouchEnd = useCallback(
187+
(e: TouchEvent) => {
188+
document.removeEventListener('touchmove', onTouchMove);
189+
document.removeEventListener('touchend', onTouchEnd);
190+
},
191+
[onTouchMove]
192+
);
193+
194+
function onMouseDown(e: React.MouseEvent<HTMLDivElement>) {
195+
e.stopPropagation();
196+
e.preventDefault();
197+
// we will only add listeners when needed, and remove them afterward
198+
document.addEventListener('mousemove', onMouseMove);
199+
document.addEventListener('mouseup', onMouseUp);
200+
}
201+
202+
function onTouchStart(e: React.TouchEvent<HTMLDivElement>) {
203+
e.stopPropagation();
204+
e.preventDefault();
205+
// we will only add listeners when needed, and remove them afterward
206+
document.addEventListener('touchmove', onTouchMove);
207+
document.addEventListener('touchend', onTouchEnd);
208+
}
209+
210+
return [drawerWidth, onMouseDown, onTouchStart];
211+
}
212+
213+
function getCustomDrawerWidth(clientX: number) {
214+
let offsetRight = document.body.offsetWidth - (clientX - document.body.offsetLeft);
215+
let widthPercent = Math.min((offsetRight / document.body.clientWidth) * 100, 98).toFixed(2);
216+
return `${widthPercent}vw`;
217+
}
218+
219+
function useBodyClassWhileOpen() {
220+
useEffect(() => {
221+
if (!document.body) {
222+
return;
223+
}
224+
225+
document.body.classList.add('body-drawer-open');
226+
227+
return () => {
228+
document.body.classList.remove('body-drawer-open');
229+
};
230+
}, []);
231+
}
232+
233+
const getStyles = (theme: GrafanaTheme2) => {
234+
return {
235+
container: css({
236+
display: 'flex',
237+
flexDirection: 'column',
238+
height: '100%',
239+
flex: '1 1 0',
240+
minHeight: '100%',
241+
position: 'relative',
242+
}),
243+
drawer: css({
244+
top: 0,
245+
position: 'absolute !important' as 'absolute',
246+
247+
'.rc-drawer-content-wrapper': {
248+
boxShadow: theme.shadows.z3,
249+
},
250+
}),
251+
drawerContent: css({
252+
backgroundColor: `${theme.colors.background.primary} !important`,
253+
display: 'flex',
254+
overflow: 'unset !important',
255+
flexDirection: 'column',
256+
}),
257+
drawerMotion: css({
258+
'&-appear': {
259+
transform: 'translateX(100%)',
260+
transition: 'none !important',
261+
262+
'&-active': {
263+
transition: `${theme.transitions.create('transform')} !important`,
264+
transform: 'translateX(0)',
265+
},
266+
},
267+
}),
268+
// we want the mask itself to span the whole page including the top bar
269+
// this ensures trying to click something in the top bar will close the drawer correctly
270+
// but we don't want the backdrop styling to apply over the top bar as it looks weird
271+
// instead have a child pseudo element to apply the backdrop styling below the top bar
272+
mask: css({
273+
// The !important here is to override the default .rc-drawer-mask styles
274+
backgroundColor: 'transparent !important',
275+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
276+
position: 'absolute !important' as 'absolute',
277+
278+
'&:before': {
279+
backgroundColor: `${theme.components.overlay.background} !important`,
280+
bottom: 0,
281+
content: '""',
282+
left: 0,
283+
position: 'absolute',
284+
right: 0,
285+
top: 0,
286+
},
287+
}),
288+
maskMotion: css({
289+
'&-appear': {
290+
opacity: 0,
291+
292+
'&-active': {
293+
opacity: 1,
294+
transition: theme.transitions.create('opacity'),
295+
},
296+
},
297+
}),
298+
header: css({
299+
label: 'drawer-header',
300+
flexGrow: 0,
301+
padding: theme.spacing(2, 2, 3),
302+
borderBottom: `1px solid ${theme.colors.border.weak}`,
303+
}),
304+
headerWithTabs: css({
305+
borderBottom: 'none',
306+
}),
307+
actions: css({
308+
position: 'absolute',
309+
right: theme.spacing(1),
310+
top: theme.spacing(1),
311+
}),
312+
titleWrapper: css({
313+
label: 'drawer-title',
314+
overflowWrap: 'break-word',
315+
}),
316+
subtitle: css({
317+
label: 'drawer-subtitle',
318+
color: theme.colors.text.secondary,
319+
paddingTop: theme.spacing(1),
320+
}),
321+
content: css({
322+
padding: theme.spacing(theme.components.drawer?.padding ?? 2),
323+
height: '100%',
324+
flexGrow: 1,
325+
minHeight: 0,
326+
}),
327+
tabsWrapper: css({
328+
label: 'drawer-tabs',
329+
paddingLeft: theme.spacing(2),
330+
margin: theme.spacing(1, -1, -3, -3),
331+
}),
332+
resizer: css({
333+
top: 0,
334+
left: theme.spacing(-1),
335+
bottom: 0,
336+
position: 'absolute',
337+
zIndex: theme.zIndex.modal,
338+
}),
339+
};
340+
};
341+
342+
function getWrapperStyles(theme: GrafanaTheme2, size: 'sm' | 'md' | 'lg') {
343+
return css({
344+
label: `drawer-content-wrapper-${size}`,
345+
overflow: 'unset !important',
346+
347+
[theme.breakpoints.down('md')]: {
348+
width: `calc(100% - ${theme.spacing(2)}) !important`,
349+
minWidth: '0 !important',
350+
},
351+
});
352+
}

src/pages/Explore/SmartDrawer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { css } from '@emotion/css';
22
import React from 'react';
33
import { GrafanaTheme2 } from '@grafana/data';
4-
import { Button, Drawer, useStyles2 } from '@grafana/ui';
4+
import { Button, useStyles2 } from '@grafana/ui';
5+
import { Drawer } from './Drawer';
56

67
interface SmartDrawerProps {
78
children: React.ReactNode;

0 commit comments

Comments
 (0)