Skip to content

Commit 4cace3a

Browse files
authored
feat(replay): Add a dropdown to make it easier to select replays inside stories (#97713)
Now we can pick replays instead of having to go copy+paste the replayId from the product. Checkout the example at `/stories/?name=app%2Fcomponents%2Freplays%2Fplayer%2FreplayPlayer.stories.tsx` <img width="869" height="537" alt="SCR-20250812-mhxi" src="https://github.com/user-attachments/assets/c409b586-d6cf-4ea4-98a3-1c4ddf224574" /> A little meta too, because there's a story for the list of replays which is used in stories. | Inline on page | In a Hovercard | | --- | --- | | <img width="482" height="644" alt="Screenshot 2025-08-12 at 1 44 39 PM" src="https://github.com/user-attachments/assets/6d3b9393-1294-4c59-a9c3-2f604a4b3e6d" /> | <img width="319" height="624" alt="SCR-20250812-mgxp" src="https://github.com/user-attachments/assets/5c715ac1-6f61-4ba5-95af-aa3525e3e69d" /> | This is also something that could be helpful in the product in the future, maybe.
1 parent 72f80ae commit 4cace3a

File tree

6 files changed

+505
-51
lines changed

6 files changed

+505
-51
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {useState} from 'react';
2+
import {ClassNames} from '@emotion/react';
3+
4+
import {Flex} from 'sentry/components/core/layout/flex';
5+
import {Hovercard} from 'sentry/components/hovercard';
6+
import ReplayList from 'sentry/components/replays/list/__stories__/replayList';
7+
import EnvironmentPicker from 'sentry/components/replays/player/__stories__/environmentPicker';
8+
import ProjectPicker from 'sentry/components/replays/player/__stories__/projectPicker';
9+
import * as Storybook from 'sentry/stories';
10+
import {useInfiniteApiQuery} from 'sentry/utils/queryClient';
11+
import useReplayListQueryKey from 'sentry/utils/replays/hooks/useReplayListQueryKey';
12+
import useOrganization from 'sentry/utils/useOrganization';
13+
import type {ReplayListRecord} from 'sentry/views/replays/types';
14+
15+
export default Storybook.story('ReplayList', story => {
16+
story('Rendered', () => {
17+
const organization = useOrganization();
18+
const [project, setProject] = useState<string | undefined>();
19+
const [environment, setEnvironment] = useState<string | undefined>();
20+
const [replayId, setReplayId] = useState<string | undefined>();
21+
22+
const query = {
23+
environment: environment ? [environment] : undefined,
24+
project: project ? [project] : undefined,
25+
sort: '-started_at',
26+
statsPeriod: '90d',
27+
};
28+
29+
const listQueryKey = useReplayListQueryKey({
30+
options: {query},
31+
organization,
32+
queryReferrer: 'replayList',
33+
});
34+
const queryResult = useInfiniteApiQuery<{data: ReplayListRecord[]}>({
35+
queryKey: ['infinite', ...(listQueryKey ?? '')],
36+
enabled: Boolean(listQueryKey),
37+
});
38+
39+
return (
40+
<Flex direction="column" gap="md">
41+
Selected Replay: {replayId}
42+
<Flex gap="sm">
43+
<ProjectPicker project={project} onChange={setProject} />
44+
<EnvironmentPicker
45+
project={project}
46+
environment={environment}
47+
onChange={setEnvironment}
48+
/>
49+
</Flex>
50+
<Flex style={{height: 500}}>
51+
<Flex direction="column" gap="md" flex="1">
52+
<ReplayList onSelect={setReplayId} queryResult={queryResult} />
53+
</Flex>
54+
</Flex>
55+
</Flex>
56+
);
57+
});
58+
59+
story('Hovercard', () => {
60+
const organization = useOrganization();
61+
62+
const [project, setProject] = useState<string | undefined>();
63+
const [environment, setEnvironment] = useState<string | undefined>();
64+
65+
const [replayId, setReplayId] = useState<string | undefined>();
66+
67+
const query = {
68+
environment: environment ? [environment] : undefined,
69+
project: project ? [project] : undefined,
70+
sort: '-started_at',
71+
statsPeriod: '90d',
72+
};
73+
74+
const listQueryKey = useReplayListQueryKey({
75+
options: {query},
76+
organization,
77+
queryReferrer: 'replayList',
78+
});
79+
const queryResult = useInfiniteApiQuery<{data: ReplayListRecord[]}>({
80+
queryKey: ['infinite', ...(listQueryKey ?? '')],
81+
enabled: Boolean(listQueryKey),
82+
});
83+
84+
return (
85+
<ClassNames>
86+
{({css}) => (
87+
<Hovercard
88+
body={
89+
<Flex direction="column" gap="md">
90+
<Flex gap="sm">
91+
<ProjectPicker project={project} onChange={setProject} />
92+
<EnvironmentPicker
93+
project={project}
94+
environment={environment}
95+
onChange={setEnvironment}
96+
/>
97+
</Flex>
98+
<Flex style={{height: 500}}>
99+
<Flex direction="column" gap="md" flex="1">
100+
<ReplayList onSelect={setReplayId} queryResult={queryResult} />
101+
</Flex>
102+
</Flex>
103+
</Flex>
104+
}
105+
containerClassName={css`
106+
width: max-content;
107+
`}
108+
>
109+
Selected Replay: {replayId}
110+
</Hovercard>
111+
)}
112+
</ClassNames>
113+
);
114+
});
115+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import styled from '@emotion/styled';
2+
import uniqBy from 'lodash/uniqBy';
3+
4+
import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg';
5+
6+
import type {ApiResult} from 'sentry/api';
7+
import {Tooltip} from 'sentry/components/core/tooltip';
8+
import ErrorBoundary from 'sentry/components/errorBoundary';
9+
import InfiniteListItems from 'sentry/components/infiniteList/infiniteListItems';
10+
import InfiniteListState from 'sentry/components/infiniteList/infiniteListState';
11+
import LoadingIndicator from 'sentry/components/loadingIndicator';
12+
import ReplayListItem from 'sentry/components/replays/list/__stories__/replayListItem';
13+
import {t} from 'sentry/locale';
14+
import {type InfiniteData, type UseInfiniteQueryResult} from 'sentry/utils/queryClient';
15+
import type {ReplayListRecord} from 'sentry/views/replays/types';
16+
17+
interface Props {
18+
onSelect: (replayId: string) => void;
19+
queryResult: UseInfiniteQueryResult<
20+
InfiniteData<ApiResult<{data: ReplayListRecord[]}>>
21+
>;
22+
}
23+
24+
export default function ReplayList({onSelect, queryResult}: Props) {
25+
return (
26+
<InfiniteListState
27+
queryResult={queryResult}
28+
backgroundUpdatingMessage={() => null}
29+
loadingMessage={() => <LoadingIndicator />}
30+
>
31+
<InfiniteListItems<ReplayListRecord, ApiResult<{data: ReplayListRecord[]}>>
32+
deduplicateItems={pages => pages.flatMap(page => uniqBy(page[0].data, 'id'))}
33+
estimateSize={() => 24}
34+
queryResult={queryResult}
35+
itemRenderer={({item, virtualItem}) => (
36+
<ErrorBoundary mini>
37+
<ReplayListItem
38+
replay={item}
39+
rowIndex={virtualItem.index}
40+
onClick={() => onSelect(item.id)}
41+
/>
42+
</ErrorBoundary>
43+
)}
44+
emptyMessage={() => <NoReplays />}
45+
loadingMoreMessage={() => (
46+
<Centered>
47+
<Tooltip title={t('Loading more replays...')}>
48+
<LoadingIndicator mini />
49+
</Tooltip>
50+
</Centered>
51+
)}
52+
loadingCompleteMessage={() => null}
53+
/>
54+
</InfiniteListState>
55+
);
56+
}
57+
58+
function NoReplays() {
59+
return (
60+
<NoReplaysWrapper>
61+
<img src={waitingForEventImg} alt={t('A person waiting for a phone to ring')} />
62+
<NoReplaysMessage>{t('Inbox Zero')}</NoReplaysMessage>
63+
<p>{t('You have two options: take a nap or be productive.')}</p>
64+
</NoReplaysWrapper>
65+
);
66+
}
67+
68+
const Centered = styled('div')`
69+
justify-self: center;
70+
`;
71+
72+
const NoReplaysWrapper = styled('div')`
73+
padding: ${p => p.theme.space['3xl']};
74+
text-align: center;
75+
color: ${p => p.theme.subText};
76+
77+
@media (max-width: ${p => p.theme.breakpoints.sm}) {
78+
font-size: ${p => p.theme.fontSize.md};
79+
}
80+
`;
81+
82+
const NoReplaysMessage = styled('div')`
83+
font-weight: ${p => p.theme.fontWeight.bold};
84+
color: ${p => p.theme.gray400};
85+
86+
@media (min-width: ${p => p.theme.breakpoints.sm}) {
87+
font-size: ${p => p.theme.fontSize.xl};
88+
}
89+
`;
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import styled from '@emotion/styled';
2+
import invariant from 'invariant';
3+
4+
import {ProjectAvatar} from 'sentry/components/core/avatar/projectAvatar';
5+
import {UserAvatar} from 'sentry/components/core/avatar/userAvatar';
6+
import InteractionStateLayer from 'sentry/components/core/interactionStateLayer';
7+
import {Flex} from 'sentry/components/core/layout/flex';
8+
import {Text} from 'sentry/components/core/text';
9+
import TimeSince from 'sentry/components/timeSince';
10+
import {IconCalendar} from 'sentry/icons/iconCalendar';
11+
import {IconDelete} from 'sentry/icons/iconDelete';
12+
import {t} from 'sentry/locale';
13+
import {space} from 'sentry/styles/space';
14+
import {getShortEventId} from 'sentry/utils/events';
15+
import useOrganization from 'sentry/utils/useOrganization';
16+
import useProjectFromId from 'sentry/utils/useProjectFromId';
17+
import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysWithTxData';
18+
import {makeReplaysPathname} from 'sentry/views/replays/pathnames';
19+
import type {ReplayListRecord} from 'sentry/views/replays/types';
20+
21+
interface Props {
22+
onClick: () => void;
23+
replay: ReplayListRecord | ReplayListRecordWithTx;
24+
rowIndex: number;
25+
}
26+
27+
export default function ReplayListItem({replay, onClick}: Props) {
28+
const organization = useOrganization();
29+
const project = useProjectFromId({project_id: replay.project_id ?? undefined});
30+
31+
const replayDetailsPathname = makeReplaysPathname({
32+
path: `/${replay.id}/`,
33+
organization,
34+
});
35+
36+
if (replay.is_archived) {
37+
return (
38+
<Flex gap="md" align="center" justify="center">
39+
<ArchivedWrapper>
40+
<IconDelete color="gray500" size="md" />
41+
</ArchivedWrapper>
42+
43+
<Flex direction="column" gap="xs">
44+
<DisplayName>{t('Deleted Replay')}</DisplayName>
45+
<Flex gap="xs" align="center">
46+
{project ? <ProjectAvatar size={12} project={project} /> : null}
47+
<Text size="sm">{getShortEventId(replay.id)}</Text>
48+
</Flex>
49+
</Flex>
50+
</Flex>
51+
);
52+
}
53+
54+
invariant(
55+
replay.started_at,
56+
'For TypeScript: replay.started_at is implied because replay.is_archived is false'
57+
);
58+
59+
return (
60+
<CardSpacing>
61+
<a
62+
href={replayDetailsPathname}
63+
onClick={e => {
64+
e.preventDefault();
65+
onClick();
66+
}}
67+
>
68+
<Flex align="center" gap="md" padding="xs">
69+
<UserAvatar
70+
user={{
71+
username: replay.user?.display_name || '',
72+
email: replay.user?.email || '',
73+
id: replay.user?.id || '',
74+
ip_address: replay.user?.ip || '',
75+
name: replay.user?.username || '',
76+
}}
77+
size={24}
78+
/>
79+
<SubText>
80+
<Flex gap="xs" align="start">
81+
<DisplayName data-underline-on-hover>
82+
{replay.user.display_name || t('Anonymous User')}
83+
</DisplayName>
84+
</Flex>
85+
<Flex gap="xs">
86+
{/* Avatar is used instead of ProjectBadge because using ProjectBadge increases spacing, which doesn't look as good */}
87+
{project ? <ProjectAvatar size={12} project={project} /> : null}
88+
{project ? <span>{project.slug}</span> : null}
89+
<span>{getShortEventId(replay.id)}</span>
90+
<Flex gap="xs">
91+
<IconCalendar color="gray300" size="xs" />
92+
<TimeSince date={replay.started_at} />
93+
</Flex>
94+
</Flex>
95+
</SubText>
96+
<InteractionStateLayer />
97+
</Flex>
98+
</a>
99+
</CardSpacing>
100+
);
101+
}
102+
103+
const CardSpacing = styled('div')`
104+
position: relative;
105+
padding: ${space(0.5)} ${space(0.5)} 0 ${space(0.5)};
106+
`;
107+
108+
const ArchivedWrapper = styled(Flex)`
109+
width: ${p => p.theme.space['2xl']};
110+
align-items: center;
111+
justify-content: center;
112+
`;
113+
114+
const SubText = styled('div')`
115+
font-size: 0.875em;
116+
line-height: normal;
117+
color: ${p => p.theme.subText};
118+
${p => p.theme.overflowEllipsis};
119+
display: flex;
120+
flex-direction: column;
121+
gap: ${space(0.25)};
122+
align-items: flex-start;
123+
`;
124+
125+
const DisplayName = styled('span')`
126+
color: ${p => p.theme.textColor};
127+
font-size: ${p => p.theme.fontSize.md};
128+
font-weight: ${p => p.theme.fontWeight.bold};
129+
line-height: normal;
130+
${p => p.theme.overflowEllipsis};
131+
132+
&:hover {
133+
color: ${p => p.theme.textColor};
134+
}
135+
`;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {useMemo} from 'react';
2+
import uniq from 'lodash/uniq';
3+
4+
import {CompactSelect} from 'sentry/components/core/compactSelect';
5+
import useProjects from 'sentry/utils/useProjects';
6+
7+
export default function EnvironmentPicker({
8+
environment,
9+
onChange,
10+
project,
11+
}: {
12+
environment: string | undefined;
13+
onChange: (environment: string) => void;
14+
project: string | undefined;
15+
}) {
16+
const {projects} = useProjects();
17+
const environments = uniq(
18+
projects
19+
.filter(p => (project ? p.id === project : false))
20+
.flatMap(p => p.environments)
21+
);
22+
23+
const options = useMemo(
24+
() => environments.map(env => ({label: env, value: env})),
25+
[environments]
26+
);
27+
28+
return (
29+
<CompactSelect
30+
onChange={selected => onChange(selected.value)}
31+
options={options}
32+
searchable
33+
size="xs"
34+
triggerProps={{prefix: 'Environment'}}
35+
value={environment}
36+
/>
37+
);
38+
}

0 commit comments

Comments
 (0)