Skip to content

Commit cb4706d

Browse files
authored
ref(replay): Refactor loading and error states for Replay Details page (#92230)
The only real visible changes are related to the header in error and loading scenarios, and padding in the Archived state. Nav breadrcumbs now filter empty labels in all cases, prefering to show "Session Replay" instead of "Session Replay > >" while loading. The color contract of placeholders in the header is terrible, but they're all present with the same dimensions as before. | State | Before | After | | --- | --- | --- | | Archived/Deleted | <img width="1309" alt="archived-before" src="https://github.com/user-attachments/assets/4665a048-c6b9-48be-abeb-474dce2918c4" /> | <img width="1312" alt="archived-after" src="https://github.com/user-attachments/assets/6d1e3415-e141-497a-9a1a-5b6bb3067306" /> | Not Found | <img width="1309" alt="not-found-before" src="https://github.com/user-attachments/assets/cef3a63e-1947-45f3-a23c-645f160ff7a6" /> | <img width="1308" alt="not-found-after" src="https://github.com/user-attachments/assets/851b8f90-c499-42a9-a00b-589dbc16fb1e" /> | Loading | https://github.com/user-attachments/assets/87014604-329b-4b82-9a8e-d11c5b925808 | https://github.com/user-attachments/assets/716a31fe-0316-4c3e-8140-07d04c2f621e
1 parent ea4ba67 commit cb4706d

11 files changed

+505
-447
lines changed

static/app/components/replays/header/detailsPageBreadcrumbs.tsx

Lines changed: 0 additions & 69 deletions
This file was deleted.

static/app/components/replays/header/replayMetaData.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,19 @@ import {useLocation} from 'sentry/utils/useLocation';
1515
import {useRoutes} from 'sentry/utils/useRoutes';
1616
import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
1717

18-
type Props = {
18+
interface Props {
1919
replayErrors: ReplayError[];
20-
replayRecord: ReplayRecord | undefined;
21-
isLoading?: boolean;
20+
replayRecord: ReplayRecord;
2221
showDeadRageClicks?: boolean;
23-
};
22+
}
2423

25-
function ReplayMetaData({
24+
export default function ReplayMetaData({
2625
replayErrors,
2726
replayRecord,
2827
showDeadRageClicks = true,
29-
isLoading,
3028
}: Props) {
29+
const nonFeedbackErrors = replayErrors.filter(e => e.title !== 'User Feedback');
30+
3131
const location = useLocation();
3232
const routes = useRoutes();
3333
const referrer = getRouteStringFromRoutes(routes);
@@ -43,9 +43,7 @@ function ReplayMetaData({
4343
},
4444
};
4545

46-
return isLoading ? (
47-
<Placeholder height="47px" width="203px" />
48-
) : (
46+
return (
4947
<KeyMetrics>
5048
{showDeadRageClicks && (
5149
<Fragment>
@@ -86,7 +84,7 @@ function ReplayMetaData({
8684
<KeyMetricLabel>{t('Errors')}</KeyMetricLabel>
8785
<KeyMetricData>
8886
{replayRecord ? (
89-
<ErrorCounts replayErrors={replayErrors} replayRecord={replayRecord} />
87+
<ErrorCounts replayErrors={nonFeedbackErrors} replayRecord={replayRecord} />
9088
) : (
9189
<Placeholder width="20px" height="16px" />
9290
)}
@@ -142,5 +140,3 @@ const ClickCount = styled(Count)`
142140
gap: ${space(0.75)};
143141
align-items: center;
144142
`;
145-
146-
export default ReplayMetaData;

static/app/utils/replays/hooks/useLogReplayDataLoaded.tsx

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
11
import {useEffect} from 'react';
22

33
import {trackAnalytics} from 'sentry/utils/analytics';
4-
import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader';
4+
import type ReplayReader from 'sentry/utils/replays/replayReader';
55
import type {BreadcrumbFrame} from 'sentry/utils/replays/types';
66
import useOrganization from 'sentry/utils/useOrganization';
7-
import useProjectFromSlug from 'sentry/utils/useProjectFromSlug';
7+
import useProjectFromId from 'sentry/utils/useProjectFromId';
88

9-
interface Props
10-
extends Pick<
11-
ReturnType<typeof useLoadReplayReader>,
12-
'fetchError' | 'fetching' | 'projectSlug' | 'replay'
13-
> {}
9+
interface Props {
10+
projectId: string | null;
11+
replay: ReplayReader;
12+
}
1413

15-
function useLogReplayDataLoaded({fetchError, fetching, projectSlug, replay}: Props) {
14+
export default function useLogReplayDataLoaded({projectId, replay}: Props) {
1615
const organization = useOrganization();
17-
const project = useProjectFromSlug({
18-
organization,
19-
projectSlug: projectSlug ?? undefined,
16+
const project = useProjectFromId({
17+
project_id: projectId ?? undefined,
2018
});
2119

2220
useEffect(() => {
23-
if (fetching || fetchError || !replay || !project || replay.getReplay().is_archived) {
21+
if (!project || replay.getReplay().is_archived) {
2422
return;
2523
}
2624
const replayRecord = replay.getReplay();
@@ -58,7 +56,5 @@ function useLogReplayDataLoaded({fetchError, fetching, projectSlug, replay}: Pro
5856
replay_id: replayRecord.id,
5957
});
6058
}
61-
}, [organization, project, fetchError, fetching, projectSlug, replay]);
59+
}, [organization, project, replay]);
6260
}
63-
64-
export default useLogReplayDataLoaded;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {type ReactNode, useEffect} from 'react';
2+
3+
import {LocalStorageReplayPreferences} from 'sentry/components/replays/preferences/replayPreferences';
4+
import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
5+
import useInitialTimeOffsetMs from 'sentry/utils/replays/hooks/useInitialTimeOffsetMs';
6+
import useLogReplayDataLoaded from 'sentry/utils/replays/hooks/useLogReplayDataLoaded';
7+
import useMarkReplayViewed from 'sentry/utils/replays/hooks/useMarkReplayViewed';
8+
import {ReplayPlayerPluginsContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerPluginsContext';
9+
import {ReplayPlayerStateContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerStateContext';
10+
import {ReplayPreferencesContextProvider} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
11+
import {ReplayReaderProvider} from 'sentry/utils/replays/playback/providers/replayReaderProvider';
12+
import type ReplayReader from 'sentry/utils/replays/replayReader';
13+
import useOrganization from 'sentry/utils/useOrganization';
14+
import ReplayTransactionContext from 'sentry/views/replays/detail/trace/replayTransactionContext';
15+
16+
interface Props {
17+
children: ReactNode;
18+
projectSlug: string | null;
19+
replay: ReplayReader;
20+
}
21+
22+
export default function ReplayDetailsProviders({children, replay, projectSlug}: Props) {
23+
const organization = useOrganization();
24+
25+
const replayRecord = replay.getReplay();
26+
const initialTimeOffsetMs = useInitialTimeOffsetMs({
27+
orgSlug: organization.slug,
28+
projectSlug,
29+
replayId: replayRecord.id,
30+
replayStartTimestampMs: replayRecord.started_at?.getTime(),
31+
});
32+
33+
const {mutate: markAsViewed} = useMarkReplayViewed();
34+
useEffect(() => {
35+
if (projectSlug && replayRecord.id && !replayRecord.has_viewed) {
36+
markAsViewed({projectSlug, replayId: replayRecord.id});
37+
}
38+
}, [markAsViewed, organization, projectSlug, replayRecord]);
39+
40+
useLogReplayDataLoaded({projectId: replayRecord.project_id, replay});
41+
42+
return (
43+
<ReplayPreferencesContextProvider prefsStrategy={LocalStorageReplayPreferences}>
44+
<ReplayPlayerPluginsContextProvider>
45+
<ReplayReaderProvider replay={replay}>
46+
<ReplayPlayerStateContextProvider>
47+
<ReplayContextProvider
48+
analyticsContext="replay_details"
49+
initialTimeOffsetMs={initialTimeOffsetMs}
50+
isFetching={false}
51+
replay={replay}
52+
>
53+
<ReplayTransactionContext replayRecord={replayRecord}>
54+
{children}
55+
</ReplayTransactionContext>
56+
</ReplayContextProvider>
57+
</ReplayPlayerStateContextProvider>
58+
</ReplayReaderProvider>
59+
</ReplayPlayerPluginsContextProvider>
60+
</ReplayPreferencesContextProvider>
61+
);
62+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import styled from '@emotion/styled';
2+
3+
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
4+
import {DropdownMenu} from 'sentry/components/dropdownMenu';
5+
import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
6+
import * as Layout from 'sentry/components/layouts/thirds';
7+
import Placeholder from 'sentry/components/placeholder';
8+
import ConfigureMobileReplayCard from 'sentry/components/replays/configureMobileReplayCard';
9+
import ConfigureReplayCard from 'sentry/components/replays/configureReplayCard';
10+
import FeedbackButton from 'sentry/components/replays/header/feedbackButton';
11+
import ReplayLoadingState from 'sentry/components/replays/player/replayLoadingState';
12+
import {IconDelete, IconEllipsis, IconUpload} from 'sentry/icons';
13+
import {t} from 'sentry/locale';
14+
import {space} from 'sentry/styles/space';
15+
import useDeleteReplay from 'sentry/utils/replays/hooks/useDeleteReplay';
16+
import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader';
17+
import useShareReplayAtTimestamp from 'sentry/utils/replays/hooks/useShareReplayAtTimestamp';
18+
import type {ReplayRecord} from 'sentry/views/replays/types';
19+
20+
interface Props {
21+
readerResult: ReturnType<typeof useLoadReplayReader>;
22+
}
23+
24+
export default function ReplayDetailsHeaderActions({readerResult}: Props) {
25+
return (
26+
<ReplayLoadingState
27+
readerResult={readerResult}
28+
renderArchived={() => null}
29+
renderError={() => null}
30+
renderLoading={() => <Placeholder height="33px" width="203px" />}
31+
renderMissing={() => null}
32+
renderProcessingError={({replayRecord, projectSlug}) => (
33+
<ButtonActionsWrapper>
34+
<FeedbackButton />
35+
<ConfigureReplayCard />
36+
<ReplayItemDropdown replayRecord={replayRecord} projectSlug={projectSlug} />
37+
</ButtonActionsWrapper>
38+
)}
39+
>
40+
{({replay}) => (
41+
<ButtonActionsWrapper>
42+
{replay.isVideoReplay() ? <FeedbackWidgetButton /> : <FeedbackButton />}
43+
{replay.isVideoReplay() ? (
44+
<ConfigureMobileReplayCard replayRecord={replay.getReplay()} />
45+
) : (
46+
<ConfigureReplayCard />
47+
)}
48+
<ReplayItemDropdown
49+
replayRecord={replay.getReplay()}
50+
projectSlug={readerResult.projectSlug}
51+
/>
52+
</ButtonActionsWrapper>
53+
)}
54+
</ReplayLoadingState>
55+
);
56+
}
57+
58+
function ReplayItemDropdown({
59+
replayRecord,
60+
projectSlug,
61+
}: {
62+
projectSlug: string | null;
63+
replayRecord: ReplayRecord | undefined;
64+
}) {
65+
const onShareReplay = useShareReplayAtTimestamp();
66+
const onDeleteReplay = useDeleteReplay({
67+
replayId: replayRecord?.id,
68+
projectSlug,
69+
});
70+
71+
const dropdownItems: MenuItemProps[] = replayRecord
72+
? [
73+
{
74+
key: 'share',
75+
label: (
76+
<ItemSpacer>
77+
<IconUpload size="sm" />
78+
{t('Share')}
79+
</ItemSpacer>
80+
),
81+
onAction: onShareReplay,
82+
},
83+
{
84+
key: 'delete',
85+
label: (
86+
<ItemSpacer>
87+
<IconDelete size="sm" />
88+
{t('Delete')}
89+
</ItemSpacer>
90+
),
91+
onAction: onDeleteReplay,
92+
},
93+
]
94+
: [];
95+
96+
return (
97+
<DropdownMenu
98+
position="bottom-end"
99+
triggerProps={{
100+
showChevron: false,
101+
icon: <IconEllipsis color="subText" />,
102+
}}
103+
size="sm"
104+
items={dropdownItems}
105+
isDisabled={dropdownItems.length === 0}
106+
/>
107+
);
108+
}
109+
110+
// TODO(replay); This could make a lot of sense to put inside HeaderActions by default
111+
const ButtonActionsWrapper = styled(Layout.HeaderActions)`
112+
flex-direction: row;
113+
justify-content: flex-end;
114+
gap: ${space(1)};
115+
@media (max-width: ${p => p.theme.breakpoints.medium}) {
116+
margin-bottom: 0;
117+
}
118+
`;
119+
120+
const ItemSpacer = styled('div')`
121+
display: flex;
122+
gap: ${space(1)};
123+
align-items: center;
124+
`;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Placeholder from 'sentry/components/placeholder';
2+
import ReplayMetaData from 'sentry/components/replays/header/replayMetaData';
3+
import ReplayLoadingState from 'sentry/components/replays/player/replayLoadingState';
4+
import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader';
5+
6+
interface Props {
7+
readerResult: ReturnType<typeof useLoadReplayReader>;
8+
}
9+
10+
export default function ReplayDetailsMetadata({readerResult}: Props) {
11+
return (
12+
<ReplayLoadingState
13+
readerResult={readerResult}
14+
renderArchived={() => null}
15+
renderError={() => null}
16+
renderLoading={() => <Placeholder height="47px" width="203px" />}
17+
renderMissing={() => null}
18+
renderProcessingError={() => null}
19+
>
20+
{({replay}) => (
21+
<ReplayMetaData
22+
replayErrors={readerResult.errors}
23+
replayRecord={replay.getReplay()}
24+
showDeadRageClicks={!replay.isVideoReplay()}
25+
/>
26+
)}
27+
</ReplayLoadingState>
28+
);
29+
}

0 commit comments

Comments
 (0)