Skip to content

Commit ddb060f

Browse files
authored
feat(replays): add ability to travel to previous and next replay (#102583)
![playlist_demo](https://github.com/user-attachments/assets/cb8291a5-4e66-4a3e-9f41-3eb43e1da7af)
1 parent 03869d7 commit ddb060f

File tree

9 files changed

+200
-35
lines changed

9 files changed

+200
-35
lines changed

static/app/components/replays/table/replayTableColumns.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {ReactNode} from 'react';
22
import {useTheme} from '@emotion/react';
33
import styled from '@emotion/styled';
4+
import type {Query} from 'history';
45
import invariant from 'invariant';
56
import {PlatformIcon} from 'platformicons';
67

@@ -17,6 +18,7 @@ import NumericDropdownFilter from 'sentry/components/replays/table/filters/numer
1718
import OSBrowserDropdownFilter from 'sentry/components/replays/table/filters/osBrowserDropdownFilter';
1819
import ScoreBar from 'sentry/components/scoreBar';
1920
import {SimpleTable} from 'sentry/components/tables/simpleTable';
21+
import {parseStatsPeriod} from 'sentry/components/timeRangeSelector/utils';
2022
import {IconNot} from 'sentry/icons';
2123
import {IconCursorArrow} from 'sentry/icons/iconCursorArrow';
2224
import {IconFire} from 'sentry/icons/iconFire';
@@ -522,18 +524,48 @@ export const ReplaySessionColumn: ReplayTableColumn = {
522524

523525
const referrer = getRouteStringFromRoutes(routes);
524526
const eventView = EventView.fromLocation(location);
527+
528+
const {statsPeriod, start, end, ...eventViewQuery} =
529+
eventView.generateQueryStringObject();
530+
531+
const detailsTabQuery: Query = {
532+
referrer,
533+
...eventViewQuery,
534+
};
535+
536+
if (typeof statsPeriod === 'string') {
537+
const {start: playlistStart, end: playlistEnd} = parseStatsPeriod(
538+
statsPeriod,
539+
undefined,
540+
true
541+
);
542+
detailsTabQuery.playlistStart = playlistStart;
543+
detailsTabQuery.playlistEnd = playlistEnd;
544+
} else if (start && end) {
545+
detailsTabQuery.playlistStart = start;
546+
detailsTabQuery.playlistEnd = end;
547+
}
548+
549+
// Because the sort and cursor field is only generated in EventView conditionally and we
550+
// want to avoid dirtying the URL with fields, we manually add them to the query here.
551+
if (location.query.sort) {
552+
detailsTabQuery.playlistSort = location.query.sort;
553+
}
554+
if (location.query.cursor) {
555+
detailsTabQuery.cursor = location.query.cursor;
556+
}
557+
525558
const replayDetailsPathname = makeReplaysPathname({
526559
path: `/${replay.id}/`,
527560
organization,
528561
});
529562

530-
const detailsTab = () => ({
531-
pathname: replayDetailsPathname,
532-
query: {
533-
referrer,
534-
...eventView.generateQueryStringObject(),
535-
},
536-
});
563+
const detailsTab = () => {
564+
return {
565+
pathname: replayDetailsPathname,
566+
query: detailsTabQuery,
567+
};
568+
};
537569
const trackNavigationEvent = () =>
538570
trackAnalytics('replay.list-navigate-to-details', {
539571
project_id: project?.id,

static/app/components/timeRangeSelector/utils.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,20 @@ const parseStatsPeriodString = (statsPeriodString: string) => {
9090
*/
9191
export function parseStatsPeriod(
9292
statsPeriod: string,
93-
outputFormat: string | null = DATE_TIME_FORMAT
93+
outputFormat: string | null = DATE_TIME_FORMAT,
94+
utc = false
9495
): {end: string; start: string} {
9596
const {value, unit} = parseStatsPeriodString(statsPeriod);
9697

9798
const momentUnit = SUPPORTED_RELATIVE_PERIOD_UNITS[unit]!.momentUnit;
9899

99100
const format = outputFormat === null ? undefined : outputFormat;
100101

102+
const momentFn = utc ? moment.utc : moment;
103+
101104
return {
102-
start: moment().subtract(value, momentUnit).format(format),
103-
end: moment().format(format),
105+
start: momentFn().subtract(value, momentUnit).format(format),
106+
end: momentFn().format(format),
104107
};
105108
}
106109

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type {ReactNode} from 'react';
2+
import {createContext, useContext} from 'react';
3+
4+
import type {ReplayListRecord} from 'sentry/views/replays/types';
5+
6+
interface Props {
7+
children: ReactNode;
8+
replays: ReplayListRecord[] | undefined;
9+
}
10+
11+
const Context = createContext<ReplayListRecord[] | undefined>(undefined);
12+
13+
export function ReplayPlaylistProvider({children, replays}: Props) {
14+
return <Context value={replays}>{children}</Context>;
15+
}
16+
17+
export function useReplayPlaylist() {
18+
return useContext(Context);
19+
}

static/app/views/issueDetails/groupReplays/groupReplays.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ describe('GroupReplays', () => {
414414
expect(await screen.findAllByText('testDisplayName')).toHaveLength(2);
415415

416416
const expectedQuery =
417-
'query=&referrer=%2Forganizations%2F%3AorgId%2Fissues%2F%3AgroupId%2Freplays%2F&statsPeriod=14d&yAxis=count%28%29';
417+
'playlistEnd=2022-09-28T23%3A29%3A13&playlistStart=2022-09-14T23%3A29%3A13&query=&referrer=%2Forganizations%2F%3AorgId%2Fissues%2F%3AgroupId%2Freplays%2F&yAxis=count%28%29';
418418

419419
// Expect the first row to have the correct href
420420
expect(

static/app/views/performance/transactionSummary/transactionReplays/index.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ describe('TransactionReplays', () => {
207207
expect(screen.getAllByText('testDisplayName')).toHaveLength(2);
208208

209209
const expectedQuery =
210-
'project=1&query=test&referrer=replays%2F&statsPeriod=14d&yAxis=count%28%29';
210+
'playlistEnd=2022-09-28T23%3A29%3A13&playlistStart=2022-09-14T23%3A29%3A13&project=1&query=test&referrer=replays%2F&yAxis=count%28%29';
211211
// Expect the first row to have the correct href
212212
expect(
213213
screen.getByRole('link', {

static/app/views/replays/detail/body/replayDetailsProviders.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1-
import {useEffect, type ReactNode} from 'react';
1+
import {useEffect, useMemo, type ReactNode} from 'react';
22

33
import {LocalStorageReplayPreferences} from 'sentry/components/replays/preferences/replayPreferences';
44
import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
5+
import {DEFAULT_REPLAY_LIST_SORT} from 'sentry/components/replays/table/useReplayTableSort';
6+
import {useApiQuery} from 'sentry/utils/queryClient';
7+
import {decodeList, decodeScalar} from 'sentry/utils/queryString';
58
import useInitialTimeOffsetMs from 'sentry/utils/replays/hooks/useInitialTimeOffsetMs';
69
import useLogReplayDataLoaded from 'sentry/utils/replays/hooks/useLogReplayDataLoaded';
710
import useMarkReplayViewed from 'sentry/utils/replays/hooks/useMarkReplayViewed';
11+
import useReplayListQueryKey from 'sentry/utils/replays/hooks/useReplayListQueryKey';
812
import {ReplayPlayerPluginsContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerPluginsContext';
913
import {ReplayPlayerSizeContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerSizeContext';
1014
import {ReplayPlayerStateContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerStateContext';
15+
import {ReplayPlaylistProvider} from 'sentry/utils/replays/playback/providers/replayPlaylistProvider';
1116
import {ReplayPreferencesContextProvider} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
1217
import {ReplayReaderProvider} from 'sentry/utils/replays/playback/providers/replayReaderProvider';
18+
import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils';
1319
import type ReplayReader from 'sentry/utils/replays/replayReader';
20+
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
1421
import useOrganization from 'sentry/utils/useOrganization';
1522
import {ReplaySummaryContextProvider} from 'sentry/views/replays/detail/ai/replaySummaryContext';
23+
import {type ReplayListRecord} from 'sentry/views/replays/types';
1624

1725
interface Props {
1826
children: ReactNode;
@@ -38,6 +46,46 @@ export default function ReplayDetailsProviders({children, replay, projectSlug}:
3846
}
3947
}, [markAsViewed, organization, projectSlug, replayRecord]);
4048

49+
const {playlistStart, playlistEnd, playlistSort, ...query} = useLocationQuery({
50+
fields: {
51+
cursor: decodeScalar,
52+
end: decodeScalar,
53+
environment: decodeList,
54+
project: decodeList,
55+
query: decodeScalar,
56+
start: decodeScalar,
57+
utc: decodeScalar,
58+
playlistStart: decodeScalar,
59+
playlistEnd: decodeScalar,
60+
playlistSort: decodeScalar,
61+
sort: decodeScalar,
62+
},
63+
});
64+
65+
// We use the playlist prefix to make it clear that these URL params are used
66+
// for the playlist navigation, and to avoid confusion with the regular start and end params.
67+
if (playlistStart && playlistEnd) {
68+
query.start = playlistStart;
69+
query.end = playlistEnd;
70+
}
71+
query.sort =
72+
!playlistSort || playlistSort === '' ? DEFAULT_REPLAY_LIST_SORT : playlistSort;
73+
74+
const queryKey = useReplayListQueryKey({
75+
options: {query},
76+
organization,
77+
queryReferrer: 'replaysPlayList',
78+
});
79+
const {data} = useApiQuery<{
80+
data: ReplayListRecord[];
81+
enabled: boolean;
82+
}>(queryKey, {
83+
staleTime: 0,
84+
enabled: Boolean(playlistStart && playlistEnd),
85+
});
86+
87+
const replays = useMemo(() => data?.data?.map(mapResponseToReplayRecord) ?? [], [data]);
88+
4189
useLogReplayDataLoaded({projectId: replayRecord.project_id, replay});
4290

4391
return (
@@ -53,7 +101,9 @@ export default function ReplayDetailsProviders({children, replay, projectSlug}:
53101
replay={replay}
54102
>
55103
<ReplaySummaryContextProvider replay={replay} projectSlug={projectSlug}>
56-
{children}
104+
<ReplayPlaylistProvider replays={replays}>
105+
{children}
106+
</ReplayPlaylistProvider>
57107
</ReplaySummaryContextProvider>
58108
</ReplayContextProvider>
59109
</ReplayPlayerSizeContextProvider>

static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
import {useState} from 'react';
1+
import {useMemo, useRef, useState} from 'react';
22
import styled from '@emotion/styled';
33

44
import {Breadcrumbs} from 'sentry/components/breadcrumbs';
5-
import {Button} from 'sentry/components/core/button';
5+
import {Button, ButtonBar} from 'sentry/components/core/button';
6+
import {LinkButton} from 'sentry/components/core/button/linkButton';
67
import {Flex} from 'sentry/components/core/layout';
78
import {Tooltip} from 'sentry/components/core/tooltip';
89
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
910
import Placeholder from 'sentry/components/placeholder';
1011
import {useReplayContext} from 'sentry/components/replays/replayContext';
11-
import {IconCopy} from 'sentry/icons';
12+
import {IconCopy, IconNext, IconPrevious} from 'sentry/icons';
1213
import {t} from 'sentry/locale';
1314
import {defined} from 'sentry/utils';
1415
import EventView from 'sentry/utils/discover/eventView';
1516
import {getShortEventId} from 'sentry/utils/events';
1617
import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader';
18+
import {useReplayPlaylist} from 'sentry/utils/replays/playback/providers/replayPlaylistProvider';
1719
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
1820
import {useLocation} from 'sentry/utils/useLocation';
1921
import useOrganization from 'sentry/utils/useOrganization';
@@ -33,6 +35,28 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) {
3335
const [isHovered, setIsHovered] = useState(false);
3436
const {currentTime} = useReplayContext();
3537

38+
const replays = useReplayPlaylist();
39+
40+
// We use a ref to store the initial location so that we can use it to navigate to the previous and next replays
41+
// without dirtying the URL with the URL params from the tabs navigation.
42+
const initialLocation = useRef(location);
43+
44+
const currentReplayIndex = useMemo(
45+
() => replays?.findIndex(r => r.id === replayRecord?.id) ?? -1,
46+
[replays, replayRecord]
47+
);
48+
49+
const nextReplay = useMemo(
50+
() =>
51+
currentReplayIndex >= 0 && currentReplayIndex < (replays?.length ?? 0) - 1
52+
? replays?.[currentReplayIndex + 1]
53+
: undefined,
54+
[replays, currentReplayIndex]
55+
);
56+
const previousReplay = useMemo(
57+
() => (currentReplayIndex > 0 ? replays?.[currentReplayIndex - 1] : undefined),
58+
[replays, currentReplayIndex]
59+
);
3660
// Create URL with current timestamp for copying
3761
const replayUrlWithTimestamp = replayRecord
3862
? (() => {
@@ -74,22 +98,53 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) {
7498

7599
const replayCrumb = {
76100
label: replayRecord ? (
77-
<Flex
78-
align="center"
79-
gap="xs"
80-
onMouseEnter={() => setIsHovered(true)}
81-
onMouseLeave={() => setIsHovered(false)}
82-
>
83-
<div
84-
onClick={() =>
85-
copy(replayUrlWithTimestamp, {
86-
successMessage: t('Copied replay link to clipboard'),
87-
})
88-
}
101+
<Flex>
102+
<Flex
103+
align="center"
104+
gap="xs"
105+
onMouseEnter={() => setIsHovered(true)}
106+
onMouseLeave={() => setIsHovered(false)}
89107
>
90-
{getShortEventId(replayRecord?.id)}
91-
</div>
92-
{isHovered && (
108+
{organization.features.includes('replay-playlist-view') && (
109+
<Flex>
110+
<ButtonBar merged gap="0">
111+
<LinkButton
112+
size="xs"
113+
icon={<IconPrevious />}
114+
disabled={!previousReplay}
115+
to={{
116+
pathname: previousReplay
117+
? makeReplaysPathname({
118+
path: `/${previousReplay.id}/`,
119+
organization,
120+
})
121+
: undefined,
122+
query: initialLocation.current.query,
123+
}}
124+
/>
125+
<LinkButton
126+
size="xs"
127+
icon={<IconNext />}
128+
disabled={!nextReplay}
129+
to={{
130+
pathname: nextReplay
131+
? makeReplaysPathname({path: `/${nextReplay.id}/`, organization})
132+
: undefined,
133+
query: initialLocation.current.query,
134+
}}
135+
/>
136+
</ButtonBar>
137+
</Flex>
138+
)}
139+
<ShortId
140+
onClick={() =>
141+
copy(replayUrlWithTimestamp, {
142+
successMessage: t('Copied replay link to clipboard'),
143+
})
144+
}
145+
>
146+
{getShortEventId(replayRecord?.id)}
147+
</ShortId>
93148
<Tooltip title={t('Copy link to replay at current timestamp')}>
94149
<Button
95150
aria-label={t('Copy link to replay at current timestamp')}
@@ -100,10 +155,11 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) {
100155
}
101156
size="zero"
102157
borderless
158+
style={isHovered ? {} : {visibility: 'hidden'}}
103159
icon={<IconCopy size="xs" color="subText" />}
104160
/>
105161
</Tooltip>
106-
)}
162+
</Flex>
107163
</Flex>
108164
) : (
109165
<Placeholder width="100%" height="16px" />
@@ -122,3 +178,7 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) {
122178
const StyledBreadcrumbs = styled(Breadcrumbs)`
123179
padding: 0;
124180
`;
181+
182+
const ShortId = styled('div')`
183+
margin-left: 10px;
184+
`;

static/app/views/replays/detail/header/replayDetailsUserBadge.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export default function ReplayDetailsUserBadge({readerResult}: Props) {
179179
renderError={() => null}
180180
renderThrottled={() => null}
181181
renderLoading={() =>
182-
replayRecord ? badge : <Placeholder width="30%" height="45px" />
182+
replayRecord ? badge : <Placeholder width="30%" height="66px" />
183183
}
184184
renderMissing={() => null}
185185
renderProcessingError={() => badge}

static/app/views/replays/types.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ export type ReplayListLocationQuery = {
203203
export type ReplayListQueryReferrer =
204204
| 'replayList'
205205
| 'issueReplays'
206-
| 'transactionReplays';
206+
| 'transactionReplays'
207+
| 'replaysPlayList';
207208

208209
// Sync with ReplayListRecord below
209210
// Skip some fields because the backend doesn't support them in the field list:

0 commit comments

Comments
 (0)