Skip to content

Commit dc24c1d

Browse files
authored
feat(issue-details): Add URL and breadcrumbs sidebar to issue replay when in fullscreen mode (#63920)
Ref #63157 Adds the URL bar and breadcrumbs sidebar to the fullscreen view. While doing so, extracted the fullscreen and sidebar toggle buttons into new common components.
1 parent 86ca20c commit dc24c1d

File tree

8 files changed

+181
-49
lines changed

8 files changed

+181
-49
lines changed

static/app/components/events/eventReplay/replayClipPreview.spec.tsx

+48-12
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {RRWebInitFrameEventsFixture} from 'sentry-fixture/replay/rrweb';
44
import {ReplayRecordFixture} from 'sentry-fixture/replayRecord';
55

66
import {initializeOrg} from 'sentry-test/initializeOrg';
7-
import {render as baseRender, screen} from 'sentry-test/reactTestingLibrary';
7+
import {render as baseRender, screen, userEvent} from 'sentry-test/reactTestingLibrary';
88

99
import useReplayReader from 'sentry/utils/replays/hooks/useReplayReader';
1010
import ReplayReader from 'sentry/utils/replays/replayReader';
@@ -26,16 +26,6 @@ const mockEventTimestampMs = new Date('2022-09-22T16:59:41Z').getTime();
2626

2727
const mockButtonHref = `/organizations/${mockOrgSlug}/replays/761104e184c64d439ee1014b72b4d83b/?referrer=%2Forganizations%2F%3AorgId%2Fissues%2F%3AgroupId%2Freplays%2F&t=52&t_main=errors`;
2828

29-
// Mock screenfull library
30-
jest.mock('screenfull', () => ({
31-
enabled: true,
32-
isFullscreen: false,
33-
request: jest.fn(),
34-
exit: jest.fn(),
35-
on: jest.fn(),
36-
off: jest.fn(),
37-
}));
38-
3929
// Get replay data with the mocked replay reader params
4030
const mockReplay = ReplayReader.factory({
4131
replayRecord: ReplayRecordFixture({
@@ -96,7 +86,24 @@ const render: typeof baseRender = children => {
9686
);
9787
};
9888

99-
describe('ReplayPreview', () => {
89+
const mockIsFullscreen = jest.fn();
90+
91+
jest.mock('screenfull', () => ({
92+
enabled: true,
93+
get isFullscreen() {
94+
return mockIsFullscreen();
95+
},
96+
request: jest.fn(),
97+
exit: jest.fn(),
98+
on: jest.fn(),
99+
off: jest.fn(),
100+
}));
101+
102+
describe('ReplayClipPreview', () => {
103+
beforeEach(() => {
104+
mockIsFullscreen.mockReturnValue(false);
105+
});
106+
100107
it('Should render a placeholder when is fetching the replay data', () => {
101108
// Change the mocked hook to return a loading state
102109
mockUseReplayReader.mockImplementationOnce(() => {
@@ -185,4 +192,33 @@ describe('ReplayPreview', () => {
185192
mockButtonHref
186193
);
187194
});
195+
196+
it('Display URL and breadcrumbs in fullscreen mode', async () => {
197+
mockIsFullscreen.mockReturnValue(true);
198+
199+
render(
200+
<ReplayClipPreview
201+
orgSlug={mockOrgSlug}
202+
replaySlug={mockReplaySlug}
203+
eventTimestampMs={mockEventTimestampMs}
204+
/>
205+
);
206+
207+
// Should have URL bar
208+
expect(screen.getByRole('textbox', {name: 'Current URL'})).toHaveValue(
209+
'http://localhost:3000/'
210+
);
211+
212+
// Breadcrumbs sidebar should be open
213+
expect(screen.getByTestId('replay-details-breadcrumbs-tab')).toBeInTheDocument();
214+
215+
// Should filter out breadcrumbs that aren't part of the clip
216+
expect(screen.getByText('No breadcrumbs recorded')).toBeInTheDocument();
217+
218+
// Can close the breadcrumbs sidebar
219+
await userEvent.click(screen.getByRole('button', {name: 'Collapse Sidebar'}));
220+
expect(
221+
screen.queryByTestId('replay-details-breadcrumbs-tab')
222+
).not.toBeInTheDocument();
223+
});
188224
});

static/app/components/events/eventReplay/replayClipPreview.tsx

+59-17
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import {ComponentProps, Fragment, useMemo, useRef} from 'react';
1+
import {ComponentProps, Fragment, useMemo, useRef, useState} from 'react';
22
import styled from '@emotion/styled';
33
import screenfull from 'screenfull';
44

55
import {Alert} from 'sentry/components/alert';
6-
import {Button, LinkButton} from 'sentry/components/button';
6+
import {LinkButton} from 'sentry/components/button';
77
import ButtonBar from 'sentry/components/buttonBar';
88
import ErrorBoundary from 'sentry/components/errorBoundary';
99
import Placeholder from 'sentry/components/placeholder';
@@ -13,11 +13,14 @@ import {
1313
Provider as ReplayContextProvider,
1414
useReplayContext,
1515
} from 'sentry/components/replays/replayContext';
16+
import ReplayCurrentUrl from 'sentry/components/replays/replayCurrentUrl';
17+
import {ReplayFullscreenButton} from 'sentry/components/replays/replayFullscreenButton';
1618
import ReplayPlayer from 'sentry/components/replays/replayPlayer';
1719
import ReplayPlayPauseButton from 'sentry/components/replays/replayPlayPauseButton';
1820
import ReplayProcessingError from 'sentry/components/replays/replayProcessingError';
21+
import {ReplaySidebarToggleButton} from 'sentry/components/replays/replaySidebarToggleButton';
1922
import TimeAndScrubberGrid from 'sentry/components/replays/timeAndScrubberGrid';
20-
import {IconContract, IconDelete, IconExpand} from 'sentry/icons';
23+
import {IconDelete} from 'sentry/icons';
2124
import {t} from 'sentry/locale';
2225
import {space} from 'sentry/styles/space';
2326
import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
@@ -30,6 +33,8 @@ import {useRoutes} from 'sentry/utils/useRoutes';
3033
import useFullscreen from 'sentry/utils/window/useFullscreen';
3134
import useIsFullscreen from 'sentry/utils/window/useIsFullscreen';
3235
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
36+
import Breadcrumbs from 'sentry/views/replays/detail/breadcrumbs';
37+
import BrowserOSIcons from 'sentry/views/replays/detail/browserOSIcons';
3338
import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
3439
import {ReplayRecord} from 'sentry/views/replays/types';
3540

@@ -77,6 +82,7 @@ function ReplayPreviewPlayer({
7782
}) {
7883
const routes = useRoutes();
7984
const organization = useOrganization();
85+
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
8086
const isFullscreen = useIsFullscreen();
8187
const {currentTime} = useReplayContext();
8288

@@ -95,30 +101,36 @@ function ReplayPreviewPlayer({
95101

96102
return (
97103
<Fragment>
98-
<StaticPanel>
99-
<ReplayPlayer />
100-
</StaticPanel>
104+
<PlayerBreadcrumbContainer>
105+
<PlayerContextContainer>
106+
{isFullscreen ? (
107+
<ContextContainer>
108+
<ReplayCurrentUrl />
109+
<BrowserOSIcons />
110+
<ReplaySidebarToggleButton
111+
isOpen={isSidebarOpen}
112+
setIsOpen={setIsSidebarOpen}
113+
/>
114+
</ContextContainer>
115+
) : null}
116+
<StaticPanel>
117+
<ReplayPlayer />
118+
</StaticPanel>
119+
</PlayerContextContainer>
120+
{isFullscreen && isSidebarOpen ? <Breadcrumbs /> : null}
121+
</PlayerBreadcrumbContainer>
101122
<ErrorBoundary mini>
102123
<ButtonGrid>
103124
<ReplayPlayPauseButton />
104125
<Container>
105126
<TimeAndScrubberGrid />
106127
</Container>
107128
<ButtonBar gap={1}>
108-
<LinkButton size="sm" to={fullReplayUrl}>
129+
<LinkButton size="sm" to={fullReplayUrl} {...fullReplayButtonProps}>
109130
{t('See Full Replay')}
110131
</LinkButton>
111132
{showFullscreenButton ? (
112-
<Button
113-
size="sm"
114-
title={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
115-
aria-label={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
116-
icon={
117-
isFullscreen ? <IconContract size="sm" /> : <IconExpand size="sm" />
118-
}
119-
onClick={toggleFullscreen}
120-
{...fullReplayButtonProps}
121-
/>
133+
<ReplayFullscreenButton toggleFullscreen={toggleFullscreen} />
122134
) : null}
123135
</ButtonBar>
124136
</ButtonGrid>
@@ -218,11 +230,33 @@ function ReplayClipPreview({
218230
);
219231
}
220232

233+
const PlayerBreadcrumbContainer = styled(FluidHeight)`
234+
position: relative;
235+
height: 100%;
236+
`;
237+
221238
const PlayerContainer = styled(FluidHeight)`
222239
position: relative;
223240
background: ${p => p.theme.background};
224241
gap: ${space(1)};
225242
max-height: 448px;
243+
244+
:fullscreen {
245+
padding: ${space(1)};
246+
247+
${PlayerBreadcrumbContainer} {
248+
display: grid;
249+
grid-template-columns: 1fr auto;
250+
height: 100%;
251+
gap: ${space(1)};
252+
}
253+
}
254+
`;
255+
256+
const PlayerContextContainer = styled(FluidHeight)`
257+
display: flex;
258+
flex-direction: column;
259+
gap: ${space(1)};
226260
`;
227261

228262
const StaticPanel = styled(FluidHeight)`
@@ -249,4 +283,12 @@ const Container = styled('div')`
249283
justify-content: center;
250284
`;
251285

286+
const ContextContainer = styled('div')`
287+
display: grid;
288+
grid-auto-flow: column;
289+
grid-template-columns: 1fr max-content max-content;
290+
align-items: center;
291+
gap: ${space(1)};
292+
`;
293+
252294
export default ReplayClipPreview;

static/app/components/replays/breadcrumbs/breadcrumbItem.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ function BreadcrumbItem({
6767
}: Props) {
6868
const {color, description, projectSlug, title, icon, timestampMs} =
6969
getCrumbOrFrameData(frame);
70-
const {replay} = useReplayContext();
70+
const {replay, startTimeOffsetMs} = useReplayContext();
7171

7272
const forceSpan = 'category' in frame && FRAMES_WITH_BUTTONS.includes(frame.category);
7373

@@ -89,7 +89,7 @@ function BreadcrumbItem({
8989
{onClick ? (
9090
<TimestampButton
9191
startTimestampMs={startTimestampMs}
92-
timestampMs={timestampMs}
92+
timestampMs={timestampMs - startTimeOffsetMs}
9393
/>
9494
) : null}
9595
</TitleContainer>
@@ -116,7 +116,7 @@ function BreadcrumbItem({
116116
<div>
117117
<OpenReplayComparisonButton
118118
replay={replay}
119-
leftTimestamp={frame.offsetMs}
119+
leftTimestamp={frame.offsetMs - startTimeOffsetMs}
120120
rightTimestamp={
121121
(frame.data.mutations.next.timestamp as number) -
122122
(replay?.getReplay().started_at.getTime() ?? 0)

static/app/components/replays/replayCurrentUrl.tsx

+10-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Link from 'sentry/components/links/link';
66
import {useReplayContext} from 'sentry/components/replays/replayContext';
77
import TextCopyInput from 'sentry/components/textCopyInput';
88
import {Tooltip} from 'sentry/components/tooltip';
9-
import {tct} from 'sentry/locale';
9+
import {t, tct} from 'sentry/locale';
1010
import getCurrentUrl from 'sentry/utils/replays/getCurrentUrl';
1111
import useProjects from 'sentry/utils/useProjects';
1212

@@ -29,7 +29,7 @@ function ReplayCurrentUrl() {
2929

3030
if (!replay || !url) {
3131
return (
32-
<TextCopyInput size="sm" disabled>
32+
<TextCopyInput aria-label={t('Current URL')} size="sm" disabled>
3333
{''}
3434
</TextCopyInput>
3535
);
@@ -57,12 +57,18 @@ function ReplayCurrentUrl() {
5757
)}
5858
isHoverable
5959
>
60-
<TextCopyInput size="sm">{url}</TextCopyInput>
60+
<TextCopyInput aria-label={t('Current URL')} size="sm">
61+
{url}
62+
</TextCopyInput>
6163
</Tooltip>
6264
);
6365
}
6466

65-
return <TextCopyInput size="sm">{url}</TextCopyInput>;
67+
return (
68+
<TextCopyInput aria-label={t('Current URL')} size="sm">
69+
{url}
70+
</TextCopyInput>
71+
);
6672
}
6773

6874
export default ReplayCurrentUrl;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {Button} from 'sentry/components/button';
2+
import {IconContract, IconExpand} from 'sentry/icons';
3+
import {t} from 'sentry/locale';
4+
import useIsFullscreen from 'sentry/utils/window/useIsFullscreen';
5+
6+
type Props = {
7+
toggleFullscreen: () => void;
8+
};
9+
10+
export function ReplayFullscreenButton({toggleFullscreen}: Props) {
11+
const isFullscreen = useIsFullscreen();
12+
13+
return (
14+
<Button
15+
size="sm"
16+
title={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
17+
aria-label={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
18+
icon={isFullscreen ? <IconContract size="sm" /> : <IconExpand size="sm" />}
19+
onClick={toggleFullscreen}
20+
/>
21+
);
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {Button} from 'sentry/components/button';
2+
import {IconChevron} from 'sentry/icons';
3+
import {t} from 'sentry/locale';
4+
5+
type Props = {
6+
isOpen: boolean;
7+
setIsOpen: (isOpen: boolean) => void;
8+
};
9+
10+
export function ReplaySidebarToggleButton({isOpen, setIsOpen}: Props) {
11+
return (
12+
<Button
13+
size="sm"
14+
onClick={() => setIsOpen(!isOpen)}
15+
icon={<IconChevron direction={isOpen ? 'right' : 'left'} />}
16+
>
17+
{isOpen ? t('Collapse Sidebar') : t('Open Sidebar')}
18+
</Button>
19+
);
20+
}

static/app/components/replays/replayView.tsx

+5-10
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import {Fragment, useState} from 'react';
22
import styled from '@emotion/styled';
33

4-
import {Button} from 'sentry/components/button';
54
import {useReplayContext} from 'sentry/components/replays/replayContext';
65
import ReplayController from 'sentry/components/replays/replayController';
76
import ReplayCurrentUrl from 'sentry/components/replays/replayCurrentUrl';
87
import ReplayPlayer from 'sentry/components/replays/replayPlayer';
98
import ReplayProcessingError from 'sentry/components/replays/replayProcessingError';
10-
import {IconChevron} from 'sentry/icons';
11-
import {t} from 'sentry/locale';
9+
import {ReplaySidebarToggleButton} from 'sentry/components/replays/replaySidebarToggleButton';
1210
import {space} from 'sentry/styles/space';
1311
import useIsFullscreen from 'sentry/utils/window/useIsFullscreen';
1412
import Breadcrumbs from 'sentry/views/replays/detail/breadcrumbs';
@@ -34,13 +32,10 @@ function ReplayView({toggleFullscreen}: Props) {
3432
<ReplayCurrentUrl />
3533
<BrowserOSIcons />
3634
{isFullscreen ? (
37-
<Button
38-
size="sm"
39-
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
40-
icon={<IconChevron direction={isSidebarOpen ? 'right' : 'left'} />}
41-
>
42-
{isSidebarOpen ? t('Collapse Sidebar') : t('Open Sidebar')}
43-
</Button>
35+
<ReplaySidebarToggleButton
36+
isOpen={isSidebarOpen}
37+
setIsOpen={setIsSidebarOpen}
38+
/>
4439
) : null}
4540
</ContextContainer>
4641
{!isFetching && replay?.hasProcessingErrors() ? (

0 commit comments

Comments
 (0)