groupReplays.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import {Fragment, useCallback, useEffect} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import type {Location} from 'history';
  5. import {Button} from 'sentry/components/button';
  6. import * as Layout from 'sentry/components/layouts/thirds';
  7. import Placeholder from 'sentry/components/placeholder';
  8. import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
  9. import {IconPlay, IconUser} from 'sentry/icons';
  10. import {t, tn} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import type {Group} from 'sentry/types/group';
  13. import type {Organization} from 'sentry/types/organization';
  14. import {trackAnalytics} from 'sentry/utils/analytics';
  15. import {browserHistory} from 'sentry/utils/browserHistory';
  16. import type EventView from 'sentry/utils/discover/eventView';
  17. import useReplayCountForIssues from 'sentry/utils/replayCount/useReplayCountForIssues';
  18. import useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader';
  19. import useReplayList from 'sentry/utils/replays/hooks/useReplayList';
  20. import {useLocation} from 'sentry/utils/useLocation';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import useUrlParams from 'sentry/utils/useUrlParams';
  23. import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils';
  24. import useAllMobileProj from 'sentry/views/replays/detail/useAllMobileProj';
  25. import ReplayTable from 'sentry/views/replays/replayTable';
  26. import {ReplayColumn} from 'sentry/views/replays/replayTable/types';
  27. import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types';
  28. import {ReplayClipPreviewWrapper} from './replayClipPreviewWrapper';
  29. import useReplaysFromIssue from './useReplaysFromIssue';
  30. type Props = {
  31. group: Group;
  32. };
  33. const VISIBLE_COLUMNS = [
  34. ReplayColumn.REPLAY,
  35. ReplayColumn.OS,
  36. ReplayColumn.BROWSER,
  37. ReplayColumn.DURATION,
  38. ReplayColumn.COUNT_ERRORS,
  39. ReplayColumn.ACTIVITY,
  40. ];
  41. const VISIBLE_COLUMNS_MOBILE = [
  42. ReplayColumn.REPLAY,
  43. ReplayColumn.OS,
  44. ReplayColumn.DURATION,
  45. ReplayColumn.COUNT_ERRORS,
  46. ReplayColumn.ACTIVITY,
  47. ];
  48. const visibleColumns = (allMobileProj: boolean) =>
  49. allMobileProj ? VISIBLE_COLUMNS_MOBILE : VISIBLE_COLUMNS;
  50. function ReplayFilterMessage() {
  51. const hasStreamlinedUI = useHasStreamlinedUI();
  52. if (!hasStreamlinedUI) {
  53. return null;
  54. }
  55. return (
  56. <ReplayFilterText>
  57. {t('The replays shown below are not subject to search filters.')}
  58. <StyledBreak />
  59. </ReplayFilterText>
  60. );
  61. }
  62. function GroupReplays({group}: Props) {
  63. const organization = useOrganization();
  64. const location = useLocation<ReplayListLocationQuery>();
  65. const hasStreamlinedUI = useHasStreamlinedUI();
  66. const {eventView, fetchError, isFetching, pageLinks} = useReplaysFromIssue({
  67. group,
  68. location,
  69. organization,
  70. });
  71. const {allMobileProj} = useAllMobileProj({});
  72. useEffect(() => {
  73. trackAnalytics('replay.render-issues-group-list', {
  74. project_id: group.project.id,
  75. platform: group.project.platform,
  76. organization,
  77. });
  78. // we only want to fire this event once
  79. // eslint-disable-next-line react-hooks/exhaustive-deps
  80. }, []);
  81. if (!eventView) {
  82. // Shown on load and no replay data available
  83. return (
  84. <StyledLayoutPage withPadding hasStreamlinedUI={hasStreamlinedUI}>
  85. <ReplayHeader>
  86. <ReplayFilterMessage />
  87. <ReplayCountHeader>
  88. <IconUser size="sm" />
  89. {isFetching ? (
  90. <Placeholder height="18px" width="400px" />
  91. ) : (
  92. t('No replay data available.')
  93. )}
  94. </ReplayCountHeader>
  95. </ReplayHeader>
  96. <ReplayTable
  97. fetchError={fetchError}
  98. isFetching={isFetching}
  99. replays={[]}
  100. sort={undefined}
  101. visibleColumns={visibleColumns(allMobileProj)}
  102. showDropdownFilters={false}
  103. />
  104. </StyledLayoutPage>
  105. );
  106. }
  107. return (
  108. <GroupReplaysTable
  109. eventView={eventView}
  110. organization={organization}
  111. pageLinks={pageLinks}
  112. visibleColumns={visibleColumns(allMobileProj)}
  113. group={group}
  114. />
  115. );
  116. }
  117. function GroupReplaysTableInner({
  118. children,
  119. organization,
  120. group,
  121. replaySlug,
  122. setSelectedReplayIndex,
  123. selectedReplayIndex,
  124. overlayContent,
  125. replays,
  126. pageLinks,
  127. }: {
  128. children: React.ReactNode;
  129. group: Group;
  130. organization: Organization;
  131. pageLinks: string | null;
  132. replaySlug: string;
  133. replays: ReplayListRecord[] | undefined;
  134. selectedReplayIndex: number;
  135. setSelectedReplayIndex: (index: number) => void;
  136. overlayContent?: React.ReactNode;
  137. }) {
  138. const orgSlug = organization.slug;
  139. const {fetching, replay} = useLoadReplayReader({
  140. orgSlug,
  141. replaySlug,
  142. group,
  143. });
  144. const {allMobileProj} = useAllMobileProj({});
  145. return (
  146. <ReplayContextProvider
  147. analyticsContext="replay_tab"
  148. isFetching={fetching}
  149. replay={replay}
  150. autoStart
  151. >
  152. <ReplayClipPreviewWrapper
  153. orgSlug={orgSlug}
  154. replaySlug={replaySlug}
  155. group={group}
  156. pageLinks={pageLinks}
  157. selectedReplayIndex={selectedReplayIndex}
  158. setSelectedReplayIndex={setSelectedReplayIndex}
  159. visibleColumns={[ReplayColumn.PLAY_PAUSE, ...visibleColumns(allMobileProj)]}
  160. overlayContent={overlayContent}
  161. replays={replays}
  162. />
  163. {children}
  164. </ReplayContextProvider>
  165. );
  166. }
  167. const locationForFetching = {query: {}} as Location<ReplayListLocationQuery>;
  168. function GroupReplaysTable({
  169. eventView,
  170. organization,
  171. group,
  172. }: {
  173. eventView: EventView;
  174. group: Group;
  175. organization: Organization;
  176. pageLinks: string | null;
  177. visibleColumns: ReplayColumn[];
  178. }) {
  179. const location = useLocation();
  180. const urlParams = useUrlParams();
  181. const {getReplayCountForIssue} = useReplayCountForIssues({
  182. statsPeriod: '90d',
  183. });
  184. const hasStreamlinedUI = useHasStreamlinedUI();
  185. const replayListData = useReplayList({
  186. eventView,
  187. location: locationForFetching,
  188. organization,
  189. queryReferrer: 'issueReplays',
  190. });
  191. const {replays} = replayListData;
  192. const {allMobileProj} = useAllMobileProj({});
  193. const rawReplayIndex = urlParams.getParamValue('selected_replay_index');
  194. const selectedReplayIndex = parseInt(
  195. typeof rawReplayIndex === 'string' ? rawReplayIndex : '0',
  196. 10
  197. );
  198. const setSelectedReplayIndex = useCallback(
  199. (index: number) => {
  200. browserHistory.replace({
  201. pathname: location.pathname,
  202. query: {...location.query, selected_replay_index: index},
  203. });
  204. },
  205. [location]
  206. );
  207. const selectedReplay = replays?.[selectedReplayIndex];
  208. const replayCount = getReplayCountForIssue(group.id, group.issueCategory);
  209. const nextReplay = replays?.[selectedReplayIndex + 1];
  210. const nextReplayText = nextReplay?.id
  211. ? `${nextReplay.user.display_name || t('Anonymous User')}`
  212. : undefined;
  213. const overlayContent =
  214. nextReplayText && replayCount && replayCount > 1 ? (
  215. <Fragment>
  216. <UpNext>{t('Up Next')}</UpNext>
  217. <OverlayText>{nextReplayText}</OverlayText>
  218. <Button
  219. onClick={() => {
  220. setSelectedReplayIndex(selectedReplayIndex + 1);
  221. }}
  222. icon={<IconPlay size="md" />}
  223. analyticsEventKey="issue_details.replay_tab.play_next_replay"
  224. analyticsEventName="Issue Details: Replay Tab Clicked Play Next Replay"
  225. >
  226. {t('Play Now')}
  227. </Button>
  228. </Fragment>
  229. ) : undefined;
  230. const replayTable = (
  231. <ReplayTable
  232. sort={undefined}
  233. visibleColumns={[
  234. ...(selectedReplay ? [ReplayColumn.PLAY_PAUSE] : []),
  235. ...visibleColumns(allMobileProj),
  236. ]}
  237. showDropdownFilters={false}
  238. onClickPlay={setSelectedReplayIndex}
  239. fetchError={replayListData.fetchError}
  240. isFetching={replayListData.isFetching}
  241. replays={replays}
  242. />
  243. );
  244. const inner = selectedReplay ? (
  245. <GroupReplaysTableInner
  246. // Use key to force unmount/remount of component to reset the context and replay iframe
  247. key={selectedReplay.id}
  248. setSelectedReplayIndex={setSelectedReplayIndex}
  249. selectedReplayIndex={selectedReplayIndex}
  250. overlayContent={overlayContent}
  251. organization={organization}
  252. group={group}
  253. replaySlug={selectedReplay.id}
  254. pageLinks={replayListData.pageLinks}
  255. replays={replays}
  256. >
  257. {replayTable}
  258. </GroupReplaysTableInner>
  259. ) : (
  260. replayTable
  261. );
  262. return (
  263. <StyledLayoutPage withPadding hasStreamlinedUI={hasStreamlinedUI}>
  264. <ReplayHeader>
  265. <ReplayFilterMessage />
  266. <ReplayCountHeader>
  267. <IconUser size="sm" />
  268. {(replayCount ?? 0) > 50
  269. ? tn(
  270. 'There are 50+ replays for this issue across %s event',
  271. 'There are 50+ replays for this issue across %s events',
  272. group.count
  273. )
  274. : t(
  275. 'There %s for this issue across %s.',
  276. tn('is %s replay', 'are %s replays', replayCount ?? 0),
  277. tn('%s event', '%s events', group.count)
  278. )}
  279. </ReplayCountHeader>
  280. </ReplayHeader>
  281. {inner}
  282. </StyledLayoutPage>
  283. );
  284. }
  285. const StyledLayoutPage = styled(Layout.Page)<{hasStreamlinedUI?: boolean}>`
  286. background-color: ${p => p.theme.background};
  287. gap: ${space(1.5)};
  288. ${p =>
  289. p.hasStreamlinedUI &&
  290. css`
  291. border: 1px solid ${p.theme.border};
  292. border-radius: ${p.theme.borderRadius};
  293. padding: ${space(1.5)};
  294. `}
  295. `;
  296. const ReplayCountHeader = styled('div')`
  297. display: flex;
  298. align-items: center;
  299. gap: ${space(1)};
  300. `;
  301. const ReplayHeader = styled('div')`
  302. display: flex;
  303. flex-direction: column;
  304. `;
  305. const StyledBreak = styled('hr')`
  306. margin-top: ${space(1)};
  307. margin-bottom: ${space(1.5)};
  308. border-color: ${p => p.theme.border};
  309. `;
  310. const ReplayFilterText = styled('div')`
  311. color: ${p => p.theme.subText};
  312. `;
  313. const OverlayText = styled('div')`
  314. font-size: ${p => p.theme.fontSizeExtraLarge};
  315. `;
  316. const UpNext = styled('div')`
  317. line-height: 0;
  318. `;
  319. export default GroupReplays;