Browse Source

change ux for playing replays on issue details page (#67176)

This PR changes the UX of the replay on issue details. Specifically,
adding the overlay at the end of the video as well as the new `See All
Replays` button.

Before:
![Screenshot 2024-03-18 at 1 19
27 PM](https://github.com/getsentry/sentry/assets/8533851/ff379e6f-b878-4e66-913a-98cc0310ce84)


After:
![Screenshot 2024-03-18 at 1 17
45 PM](https://github.com/getsentry/sentry/assets/8533851/6ea6dfc8-b3f0-48cc-b56b-fcf48b5793c5)
Stephen Cefali 11 months ago
parent
commit
6256570ce4

+ 5 - 0
static/app/components/events/eventReplay/index.spec.tsx

@@ -116,6 +116,11 @@ describe('EventReplay', function () {
 
   beforeEach(function () {
     const project = ProjectFixture({platform: 'javascript'});
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/replay-count/`,
+      method: 'GET',
+      body: {},
+    });
 
     jest.mocked(useProjects).mockReturnValue({
       fetchError: null,

+ 38 - 2
static/app/components/events/eventReplay/index.tsx

@@ -1,7 +1,8 @@
-import {useCallback} from 'react';
+import {Fragment, useCallback} from 'react';
 import ReactLazyLoad from 'react-lazyload';
 import styled from '@emotion/styled';
 
+import {LinkButton} from 'sentry/components/button';
 import NegativeSpaceContainer from 'sentry/components/container/negativeSpaceContainer';
 import ErrorBoundary from 'sentry/components/errorBoundary';
 import {REPLAY_LOADING_HEIGHT} from 'sentry/components/events/eventReplay/constants';
@@ -10,15 +11,19 @@ import LazyLoad from 'sentry/components/lazyLoad';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {ReplayGroupContextProvider} from 'sentry/components/replays/replayGroupContext';
 import {replayBackendPlatforms} from 'sentry/data/platformCategories';
+import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {Group} from 'sentry/types';
 import type {Event} from 'sentry/types/event';
 import {getAnalyticsDataForEvent, getAnalyticsDataForGroup} from 'sentry/utils/events';
+import useReplayCountForIssues from 'sentry/utils/replayCount/useReplayCountForIssues';
 import {getReplayIdFromEvent} from 'sentry/utils/replays/getReplayIdFromEvent';
 import {useHasOrganizationSentAnyReplayEvents} from 'sentry/utils/replays/hooks/useReplayOnboarding';
 import {projectCanUpsellReplay} from 'sentry/utils/replays/projectSupportsReplay';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjectFromSlug from 'sentry/utils/useProjectFromSlug';
+import useRouter from 'sentry/utils/useRouter';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 
 type Props = {
   event: Event;
@@ -38,6 +43,8 @@ function EventReplayContent({
 }: Props & {replayId: undefined | string}) {
   const organization = useOrganization();
   const {hasOrgSentReplays, fetching} = useHasOrganizationSentAnyReplayEvents();
+  const router = useRouter();
+  const {getReplayCountForIssue} = useReplayCountForIssues();
 
   const replayOnboardingPanel = useCallback(
     () => import('./replayInlineOnboardingPanel'),
@@ -100,8 +107,36 @@ function EventReplayContent({
     ),
   };
 
+  // don't try to construct the url if we don't have a group
+  const eventIdFromRouter = router.params.eventId;
+  const baseUrl = group
+    ? eventIdFromRouter
+      ? normalizeUrl(
+          `/organizations/${organization.slug}/issues/${group.id}/events/${eventIdFromRouter}/`
+        )
+      : normalizeUrl(`/organizations/${organization.slug}/issues/${group.id}/`)
+    : '';
+  const replayUrl = baseUrl ? `${baseUrl}replays/${location.search}/` : '';
+  const seeAllReplaysButton = replayUrl ? (
+    <LinkButton size="sm" to={replayUrl}>
+      {t('See All Replays')}
+    </LinkButton>
+  ) : undefined;
+  const replayCount = group ? getReplayCountForIssue(group.id, group.issueCategory) : -1;
+  const overlayContent =
+    seeAllReplaysButton && replayCount && replayCount > 1 ? (
+      <Fragment>
+        <div>
+          {tct('Replay captured [replayCount] users experiencing this issue', {
+            replayCount,
+          })}
+        </div>
+        {seeAllReplaysButton}
+      </Fragment>
+    ) : undefined;
+
   return (
-    <ReplaySectionMinHeight>
+    <ReplaySectionMinHeight actions={seeAllReplaysButton}>
       <ErrorBoundary mini>
         <ReplayGroupContextProvider groupId={group?.id} eventId={event.id}>
           <ReactLazyLoad debounce={50} height={448} offset={0} once>
@@ -110,6 +145,7 @@ function EventReplayContent({
                 {...commonProps}
                 component={replayClipPreview}
                 clipOffsets={CLIP_OFFSETS}
+                overlayContent={overlayContent}
               />
             ) : (
               <LazyLoad {...commonProps} component={replayPreview} />

+ 3 - 5
static/app/components/events/eventReplay/replayClipPreviewPlayer.tsx

@@ -33,7 +33,7 @@ type Props = {
   handleForwardClick?: () => void;
   isLarge?: boolean;
   onClickNextReplay?: () => void;
-  overlayText?: string;
+  overlayContent?: React.ReactNode;
   showNextAndPrevious?: boolean;
 } & ReturnType<typeof useReplayReader>;
 
@@ -65,14 +65,13 @@ function ReplayClipPreviewPlayer({
   isLarge,
   handleForwardClick,
   handleBackClick,
-  overlayText,
+  overlayContent,
   fetching,
   replay,
   replayRecord,
   fetchError,
   replayId,
   showNextAndPrevious,
-  onClickNextReplay,
 }: Props) {
   useRouteAnalyticsParams({
     event_replay_status: getReplayAnalyticsStatus({fetchError, replayRecord}),
@@ -125,9 +124,8 @@ function ReplayClipPreviewPlayer({
           replayRecord={replayRecord}
           handleBackClick={handleBackClick}
           handleForwardClick={handleForwardClick}
-          overlayText={overlayText}
+          overlayContent={overlayContent}
           showNextAndPrevious={showNextAndPrevious}
-          onClickNextReplay={onClickNextReplay}
           // if the player is large, we want to keep the priority as default
           playPausePriority={isLarge ? 'default' : undefined}
         />

+ 3 - 8
static/app/components/events/eventReplay/replayPreviewPlayer.tsx

@@ -36,9 +36,8 @@ function ReplayPreviewPlayer({
   replayRecord,
   handleBackClick,
   handleForwardClick,
-  overlayText,
+  overlayContent,
   showNextAndPrevious,
-  onClickNextReplay,
   playPausePriority,
 }: {
   replayId: string;
@@ -46,8 +45,7 @@ function ReplayPreviewPlayer({
   fullReplayButtonProps?: Partial<ComponentProps<typeof LinkButton>>;
   handleBackClick?: () => void;
   handleForwardClick?: () => void;
-  onClickNextReplay?: () => void;
-  overlayText?: string;
+  overlayContent?: React.ReactNode;
   playPausePriority?: ComponentProps<typeof ReplayPlayPauseButton>['priority'];
   showNextAndPrevious?: boolean;
 }) {
@@ -102,10 +100,7 @@ function ReplayPreviewPlayer({
               </ContextContainer>
             ) : null}
             <StaticPanel>
-              <ReplayPlayer
-                overlayText={overlayText}
-                onClickNextReplay={onClickNextReplay}
-              />
+              <ReplayPlayer overlayContent={overlayContent} />
             </StaticPanel>
           </PlayerContextContainer>
           {isFullscreen && isSidebarOpen ? <Breadcrumbs /> : null}

+ 4 - 27
static/app/components/replays/replayPlayer.tsx

@@ -2,14 +2,11 @@ import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
 import styled from '@emotion/styled';
 import {useResizeObserver} from '@react-aria/utils';
 
-import {Button} from 'sentry/components/button';
 import NegativeSpaceContainer from 'sentry/components/container/negativeSpaceContainer';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import BufferingOverlay from 'sentry/components/replays/player/bufferingOverlay';
 import FastForwardBadge from 'sentry/components/replays/player/fastForwardBadge';
 import {useReplayContext} from 'sentry/components/replays/replayContext';
-import {IconPlay} from 'sentry/icons';
-import {t} from 'sentry/locale';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import useOrganization from 'sentry/utils/useOrganization';
 
@@ -20,8 +17,7 @@ type Dimensions = ReturnType<typeof useReplayContext>['dimensions'];
 interface Props {
   className?: string;
   isPreview?: boolean;
-  onClickNextReplay?: () => void;
-  overlayText?: string;
+  overlayContent?: React.ReactNode;
 }
 
 function useVideoSizeLogger({
@@ -62,12 +58,7 @@ function useVideoSizeLogger({
   }, [organization, windowDimensions, videoDimensions, didLog, analyticsContext]);
 }
 
-function BasePlayerRoot({
-  className,
-  overlayText,
-  onClickNextReplay,
-  isPreview = false,
-}: Props) {
+function BasePlayerRoot({className, overlayContent, isPreview = false}: Props) {
   const {
     dimensions: videoDimensions,
     fastForwardSpeed,
@@ -129,15 +120,9 @@ function BasePlayerRoot({
 
   return (
     <Fragment>
-      {isFinished && overlayText && (
+      {isFinished && overlayContent && (
         <Overlay>
-          <OverlayInnerWrapper>
-            <UpNext>{t('Up Next')}</UpNext>
-            <OverlayText>{overlayText}</OverlayText>
-            <Button onClick={onClickNextReplay} icon={<IconPlay size="md" />}>
-              {t('Play Now')}
-            </Button>
-          </OverlayInnerWrapper>
+          <OverlayInnerWrapper>{overlayContent}</OverlayInnerWrapper>
         </Overlay>
       )}
       <StyledNegativeSpaceContainer ref={windowEl} className="sentry-block">
@@ -309,12 +294,4 @@ const StyledNegativeSpaceContainer = styled(NegativeSpaceContainer)`
   height: 100%;
 `;
 
-const OverlayText = styled('div')`
-  font-size: ${p => p.theme.fontSizeExtraLarge};
-`;
-
-const UpNext = styled('div')`
-  line-height: 0;
-`;
-
 export default SentryPlayerRoot;

+ 32 - 6
static/app/views/issueDetails/groupReplays/groupReplays.tsx

@@ -1,12 +1,13 @@
-import {useCallback, useEffect, useMemo, useState} from 'react';
+import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
 import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 import type {Location} from 'history';
 
+import {Button} from 'sentry/components/button';
 import * as Layout from 'sentry/components/layouts/thirds';
 import {StaticReplayPreferences} from 'sentry/components/replays/preferences/replayPreferences';
 import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
-import {IconUser} from 'sentry/icons';
+import {IconPlay, IconUser} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {Group, Organization} from 'sentry/types';
@@ -96,7 +97,7 @@ function GroupReplaysTableInner({
   replaySlug: string;
   selectedReplayIndex: number;
   setSelectedReplayIndex: (index: number) => void;
-  nextReplayText?: string;
+  overlayContent?: React.ReactNode;
 } & ReturnType<typeof useReplayList>) {
   const orgSlug = organization.slug;
   const {fetching, replay} = useReplayReader({
@@ -122,7 +123,7 @@ function GroupReplaysTableInner({
         selectedReplayIndex={props.selectedReplayIndex}
         setSelectedReplayIndex={props.setSelectedReplayIndex}
         visibleColumns={VISIBLE_COLUMNS_WITH_PLAY}
-        nextReplayText={props.nextReplayText}
+        overlayContent={props.overlayContent}
         replays={props.replays}
         isFetching={props.isFetching}
         fetchError={props.fetchError}
@@ -194,11 +195,28 @@ function GroupReplaysTable({
     }
   }, [forceHideReplay]);
 
+  const replayCount = getReplayCountForIssue(group.id, group.issueCategory);
   const nextReplay = replays?.[selectedReplayIndex + 1];
   const nextReplayText = nextReplay?.id
     ? `${nextReplay.user.display_name || t('Anonymous User')}`
     : undefined;
 
+  const overlayContent =
+    nextReplayText && replayCount && replayCount > 1 ? (
+      <Fragment>
+        <UpNext>{t('Up Next')}</UpNext>
+        <OverlayText>{nextReplayText}</OverlayText>
+        <Button
+          onClick={() => {
+            setSelectedReplayIndex(selectedReplayIndex + 1);
+          }}
+          icon={<IconPlay size="md" />}
+        >
+          {t('Play Now')}
+        </Button>
+      </Fragment>
+    ) : undefined;
+
   const hasFeature = organization.features.includes('replay-play-from-replay-tab');
 
   const inner =
@@ -206,7 +224,7 @@ function GroupReplaysTable({
       <GroupReplaysTableInner
         setSelectedReplayIndex={setSelectedReplayIndex}
         selectedReplayIndex={selectedReplayIndex}
-        nextReplayText={nextReplayText}
+        overlayContent={overlayContent}
         organization={organization}
         group={group}
         replaySlug={selectedReplay.id}
@@ -233,7 +251,7 @@ function GroupReplaysTable({
         <StyledIconUser size="sm" />
         {t(
           'Replay captured %s users experiencing this issue across %s events.',
-          getReplayCountForIssue(group.id, group.issueCategory),
+          replayCount,
           group.count
         )}
       </ReplayCountHeader>
@@ -260,4 +278,12 @@ const StyledIconUser = styled(IconUser)`
   width: 16px;
 `;
 
+const OverlayText = styled('div')`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+`;
+
+const UpNext = styled('div')`
+  line-height: 0;
+`;
+
 export default GroupReplays;

+ 3 - 10
static/app/views/issueDetails/groupReplays/replayTableWrapper.tsx

@@ -15,12 +15,12 @@ type Props = {
   selectedReplayIndex: number;
   setSelectedReplayIndex: (index: number) => void;
   visibleColumns: ReplayColumn[];
-  nextReplayText?: string;
+  overlayContent?: React.ReactNode;
 } & React.ComponentProps<typeof ReplayTable>;
 
 function ReplayTableWrapper({
   replaySlug,
-  nextReplayText,
+  overlayContent,
   setSelectedReplayIndex,
   orgSlug,
   group,
@@ -37,7 +37,7 @@ function ReplayTableWrapper({
   return (
     <Fragment>
       <ReplayClipPreviewPlayer
-        overlayText={nextReplayText}
+        overlayContent={overlayContent}
         orgSlug={orgSlug}
         showNextAndPrevious
         handleForwardClick={
@@ -54,13 +54,6 @@ function ReplayTableWrapper({
               }
             : undefined
         }
-        onClickNextReplay={
-          nextReplayText
-            ? () => {
-                setSelectedReplayIndex(selectedReplayIndex + 1);
-              }
-            : undefined
-        }
         analyticsContext={analyticsContext}
         isLarge
         {...replayReaderData}