Browse Source

ref(replay): Refactor Replay List page, and add a hook for banner(s) that can appear at the top of the page (#55152)

**Before:**
| onboarding | replays table |
| --- | --- |
|
![SCR-20230821-mozd](https://github.com/getsentry/sentry/assets/187460/03d8f948-9fae-48c3-955f-094819057db0)
|
![SCR-20230821-mphs](https://github.com/getsentry/sentry/assets/187460/b2f00774-7cbe-4a37-8b45-a799d1946f48)
|

**After:**
| onboarding | replays table |
| --- | --- |
|
![SCR-20230821-mofl](https://github.com/getsentry/sentry/assets/187460/c2cb7ff1-2f29-4038-8d2f-8c7695c65d05)
|
![SCR-20230821-mnvc](https://github.com/getsentry/sentry/assets/187460/671ef828-cf06-483a-8e89-df0a407982f8)
|
Ryan Albrecht 1 year ago
parent
commit
969ef16290

+ 33 - 23
static/app/components/replays/replayNewFeatureBanner.tsx → static/app/components/replays/pageBanner.tsx

@@ -1,38 +1,47 @@
-import {CSSProperties} from 'react';
+import type {ReactNode} from 'react';
 import styled from '@emotion/styled';
 
-import newFeatureImage from 'sentry-images/spot/alerts-new-feature-banner.svg';
-
 import {Button} from 'sentry/components/button';
 import Panel from 'sentry/components/panels/panel';
-import {IconBroadcast, IconClose} from 'sentry/icons';
+import {IconClose} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 
 interface Props {
-  description: React.ReactNode;
-  heading: React.ReactNode;
-  button?: React.ReactNode;
+  description: ReactNode;
+  heading: ReactNode;
+  icon: ReactNode;
+  image: any;
+  title: ReactNode;
+  button?: ReactNode;
   onDismiss?: () => void;
 }
 
-export function ReplayNewFeatureBanner({heading, description, button, onDismiss}: Props) {
+export default function PageBanner({
+  button,
+  description,
+  heading,
+  icon,
+  image,
+  onDismiss,
+  title,
+}: Props) {
   return (
     <Wrapper>
       {onDismiss && (
         <CloseButton
           onClick={onDismiss}
           icon={<IconClose size="xs" />}
-          aria-label={t('Feature banner close')}
+          aria-label={t('Hide')}
           size="xs"
         />
       )}
-      <Background />
+      <Background image={image} />
       <Stack>
-        <SubText uppercase fontWeight={500}>
-          <IconBroadcast />
-          <span>{t('Whats New')}</span>
-        </SubText>
+        <TypeText>
+          {icon}
+          {title}
+        </TypeText>
         <TextContainer>
           <h4>{heading}</h4>
           <SubText>{description}</SubText>
@@ -49,6 +58,7 @@ const Wrapper = styled(Panel)`
   min-height: 100px;
   justify-content: space-between;
   align-items: center;
+  margin: 0;
 `;
 
 const CloseButton = styled(Button)`
@@ -62,7 +72,7 @@ const CloseButton = styled(Button)`
   z-index: 1;
 `;
 
-const Background = styled('div')`
+const Background = styled('div')<{image: any}>`
   display: flex;
   justify-self: flex-end;
   position: absolute;
@@ -70,8 +80,9 @@ const Background = styled('div')`
   right: 0px;
   height: 100%;
   width: 50%;
+  /* Prevent the image from going behind the text, keep text readable */
   max-width: 500px;
-  background-image: url(${newFeatureImage});
+  background-image: url(${p => p.image});
   background-repeat: no-repeat;
   background-size: cover;
 `;
@@ -94,17 +105,16 @@ const TextContainer = styled('div')`
   }
 `;
 
-const SubText = styled('div')<{
-  fontSize?: 'sm';
-  fontWeight?: CSSProperties['fontWeight'];
-  uppercase?: boolean;
-}>`
+const SubText = styled('div')`
   display: flex;
-  text-transform: ${p => (p.uppercase ? 'uppercase' : undefined)};
   color: ${p => p.theme.subText};
   line-height: ${p => p.theme.fontSizeMedium};
   font-size: ${p => p.theme.fontSizeMedium};
-  font-weight: ${p => p.fontWeight};
   align-items: center;
   gap: ${space(0.5)};
 `;
+
+const TypeText = styled(SubText)`
+  text-transform: uppercase;
+  font-weight: 500;
+`;

+ 3 - 1
static/app/types/hooks.tsx

@@ -88,8 +88,9 @@ type DisabledMemberTooltipProps = {children: React.ReactNode};
 
 type DashboardHeadersProps = {organization: Organization};
 
-type ReplayOnboardingAlertProps = {children: React.ReactNode};
 type ReplayFeedbackButton = {children: React.ReactNode};
+type ReplayListPageHeaderProps = {children?: React.ReactNode};
+type ReplayOnboardingAlertProps = {children: React.ReactNode};
 type ReplayOnboardingCTAProps = {children: React.ReactNode; organization: Organization};
 type ProductUnavailableCTAProps = {organization: Organization};
 
@@ -171,6 +172,7 @@ export type ComponentHooks = {
   'component:profiling-billing-banner': () => React.ComponentType<ProfilingBetaAlertBannerProps>;
   'component:profiling-upgrade-plan-button': () => React.ComponentType<ProfilingUpgradePlanButtonProps>;
   'component:replay-feedback-button': () => React.ComponentType<ReplayFeedbackButton>;
+  'component:replay-list-page-header': () => React.ComponentType<ReplayListPageHeaderProps> | null;
   'component:replay-onboarding-alert': () => React.ComponentType<ReplayOnboardingAlertProps>;
   'component:replay-onboarding-cta': () => React.ComponentType<ReplayOnboardingCTAProps>;
   'component:sentry-logo': () => React.ComponentType<SentryLogoProps>;

+ 13 - 9
static/app/views/replays/list.tsx

@@ -1,5 +1,7 @@
+import {Fragment} from 'react';
 import styled from '@emotion/styled';
 
+import HookOrDefault from 'sentry/components/hookOrDefault';
 import * as Layout from 'sentry/components/layouts/thirds';
 import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
 import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
@@ -8,16 +10,19 @@ import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import useReplayPageview from 'sentry/utils/replays/hooks/useReplayPageview';
 import useOrganization from 'sentry/utils/useOrganization';
-import {ReplaysFilters, ReplaysSearch} from 'sentry/views/replays/list/filters';
-import ReplaysErroneousDeadRageCards from 'sentry/views/replays/list/replaysErroneousDeadRageCards';
-import ReplaysList from 'sentry/views/replays/list/replaysList';
+import ListContent from 'sentry/views/replays/list/listContent';
+
+const ReplayListPageHeaderHook = HookOrDefault({
+  hookName: 'component:replay-list-page-header',
+  defaultComponent: ({children}) => <Fragment>{children}</Fragment>,
+});
 
 function ReplaysListContainer() {
   useReplayPageview('replay.list-time-spent');
-  const {slug: orgSlug} = useOrganization();
+  const organization = useOrganization();
 
   return (
-    <SentryDocumentTitle title={`Session Replay — ${orgSlug}`}>
+    <SentryDocumentTitle title={`Session Replay — ${organization.slug}`}>
       <Layout.Header>
         <Layout.HeaderContent>
           <Layout.Title>
@@ -35,10 +40,8 @@ function ReplaysListContainer() {
         <Layout.Body>
           <Layout.Main fullWidth>
             <LayoutGap>
-              <ReplaysFilters />
-              <ReplaysErroneousDeadRageCards />
-              <ReplaysSearch />
-              <ReplaysList />
+              <ReplayListPageHeaderHook />
+              <ListContent />
             </LayoutGap>
           </Layout.Main>
         </Layout.Body>
@@ -51,4 +54,5 @@ const LayoutGap = styled('div')`
   display: grid;
   gap: ${space(2)};
 `;
+
 export default ReplaysListContainer;

+ 10 - 39
static/app/views/replays/list/filters.tsx

@@ -1,56 +1,27 @@
-import {browserHistory} from 'react-router';
+import type {ReactNode} from 'react';
 import styled from '@emotion/styled';
 
 import DatePageFilter from 'sentry/components/datePageFilter';
 import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
 import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
 import ProjectPageFilter from 'sentry/components/projectPageFilter';
-import {decodeScalar} from 'sentry/utils/queryString';
-import {useLocation} from 'sentry/utils/useLocation';
-import useOrganization from 'sentry/utils/useOrganization';
-import usePageFilters from 'sentry/utils/usePageFilters';
-import ReplaySearchBar from 'sentry/views/replays/list/replaySearchBar';
+import {space} from 'sentry/styles/space';
 
-export function ReplaysFilters() {
+export default function ReplaysFilters({children}: {children?: ReactNode}) {
   return (
-    <Container>
+    <FiltersContainer>
       <PageFilterBar condensed>
         <ProjectPageFilter resetParamsOnChange={['cursor']} />
         <EnvironmentPageFilter resetParamsOnChange={['cursor']} />
         <DatePageFilter alignDropdown="left" resetParamsOnChange={['cursor']} />
       </PageFilterBar>
-    </Container>
+      {children}
+    </FiltersContainer>
   );
 }
 
-export function ReplaysSearch() {
-  const {selection} = usePageFilters();
-  const {pathname, query} = useLocation();
-  const organization = useOrganization();
-
-  return (
-    <Container>
-      <ReplaySearchBar
-        organization={organization}
-        pageFilters={selection}
-        defaultQuery=""
-        query={decodeScalar(query.query, '')}
-        onSearch={searchQuery => {
-          browserHistory.push({
-            pathname,
-            query: {
-              ...query,
-              cursor: undefined,
-              query: searchQuery.trim(),
-            },
-          });
-        }}
-      />
-    </Container>
-  );
-}
-
-const Container = styled('div')`
-  display: inline-grid;
-  width: 100%;
+const FiltersContainer = styled('div')`
+  display: flex;
+  flex-direction: row;
+  gap: ${space(2)};
 `;

+ 11 - 7
static/app/views/replays/list/replaysList.spec.tsx → static/app/views/replays/list/listContent.spec.tsx

@@ -7,9 +7,9 @@ import {
   useReplayOnboardingSidebarPanel,
 } from 'sentry/utils/replays/hooks/useReplayOnboarding';
 import useOrganization from 'sentry/utils/useOrganization';
+import ListPage from 'sentry/views/replays/list/listContent';
 
-import ReplayList from './replaysList';
-
+jest.mock('sentry/utils/replays/hooks/useReplayPageview');
 jest.mock('sentry/utils/useOrganization');
 jest.mock('sentry/utils/replays/hooks/useReplayOnboarding');
 jest.mock('sentry/utils/replays/hooks/useReplayList', () => {
@@ -61,6 +61,10 @@ describe('ReplayList', () => {
       url: '/organizations/org-slug/sdk-updates/',
       body: [],
     });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/tags/',
+      body: [],
+    });
   });
 
   it('should render the onboarding panel when the org is on AM1', async () => {
@@ -70,7 +74,7 @@ describe('ReplayList', () => {
       hasSentOneReplay: false,
     });
 
-    render(<ReplayList />, {
+    render(<ListPage />, {
       context: getMockContext(mockOrg),
     });
 
@@ -87,7 +91,7 @@ describe('ReplayList', () => {
       hasSentOneReplay: true,
     });
 
-    render(<ReplayList />, {
+    render(<ListPage />, {
       context: getMockContext(mockOrg),
     });
 
@@ -104,7 +108,7 @@ describe('ReplayList', () => {
       hasSentOneReplay: false,
     });
 
-    render(<ReplayList />, {
+    render(<ListPage />, {
       context: getMockContext(mockOrg),
     });
 
@@ -121,11 +125,11 @@ describe('ReplayList', () => {
       hasSentOneReplay: true,
     });
 
-    render(<ReplayList />, {
+    render(<ListPage />, {
       context: getMockContext(mockOrg),
     });
 
-    await waitFor(() => expect(screen.getByTestId('replay-table')).toBeInTheDocument());
+    await waitFor(() => expect(screen.queryAllByTestId('replay-table')).toHaveLength(3));
     expect(mockUseReplayList).toHaveBeenCalled();
   });
 });

+ 33 - 0
static/app/views/replays/list/listContent.tsx

@@ -0,0 +1,33 @@
+import {Fragment} from 'react';
+
+import {useHaveSelectedProjectsSentAnyReplayEvents} from 'sentry/utils/replays/hooks/useReplayOnboarding';
+import useOrganization from 'sentry/utils/useOrganization';
+import ReplaysFilters from 'sentry/views/replays/list/filters';
+import ReplayOnboardingPanel from 'sentry/views/replays/list/replayOnboardingPanel';
+import ReplaysErroneousDeadRageCards from 'sentry/views/replays/list/replaysErroneousDeadRageCards';
+import ReplaysList from 'sentry/views/replays/list/replaysList';
+import ReplaysSearch from 'sentry/views/replays/list/search';
+
+export default function ListContent() {
+  const organization = useOrganization();
+
+  const hasSessionReplay = organization.features.includes('session-replay');
+  const {hasSentOneReplay, fetching} = useHaveSelectedProjectsSentAnyReplayEvents();
+  const showOnboarding = !hasSessionReplay || !hasSentOneReplay;
+
+  return fetching ? null : showOnboarding ? (
+    <Fragment>
+      <ReplaysFilters>
+        <ReplaysSearch />
+      </ReplaysFilters>
+      <ReplayOnboardingPanel />
+    </Fragment>
+  ) : (
+    <Fragment>
+      <ReplaysFilters />
+      <ReplaysErroneousDeadRageCards />
+      <ReplaysSearch />
+      <ReplaysList />
+    </Fragment>
+  );
+}

+ 1 - 8
static/app/views/replays/list/replaysList.tsx

@@ -11,13 +11,11 @@ import EventView from 'sentry/utils/discover/eventView';
 import {decodeScalar} from 'sentry/utils/queryString';
 import {DEFAULT_SORT} from 'sentry/utils/replays/fetchReplayList';
 import useReplayList from 'sentry/utils/replays/hooks/useReplayList';
-import {useHaveSelectedProjectsSentAnyReplayEvents} from 'sentry/utils/replays/hooks/useReplayOnboarding';
 import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import useProjectSdkNeedsUpdate from 'sentry/utils/useProjectSdkNeedsUpdate';
-import ReplayOnboardingPanel from 'sentry/views/replays/list/replayOnboardingPanel';
 import ReplayTable from 'sentry/views/replays/replayTable';
 import {ReplayColumn} from 'sentry/views/replays/replayTable/types';
 import type {ReplayListLocationQuery} from 'sentry/views/replays/types';
@@ -45,17 +43,12 @@ function ReplaysList() {
     );
   }, [location]);
 
-  const hasSessionReplay = organization.features.includes('session-replay');
-  const {hasSentOneReplay, fetching} = useHaveSelectedProjectsSentAnyReplayEvents();
-
-  return hasSessionReplay && !fetching && hasSentOneReplay ? (
+  return (
     <ReplaysListTable
       eventView={eventView}
       location={location}
       organization={organization}
     />
-  ) : (
-    <ReplayOnboardingPanel />
   );
 }
 

+ 40 - 0
static/app/views/replays/list/search.tsx

@@ -0,0 +1,40 @@
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+
+import {decodeScalar} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import ReplaySearchBar from 'sentry/views/replays/list/replaySearchBar';
+
+export default function ReplaysSearch() {
+  const {selection} = usePageFilters();
+  const {pathname, query} = useLocation();
+  const organization = useOrganization();
+
+  return (
+    <SearchContainer>
+      <ReplaySearchBar
+        organization={organization}
+        pageFilters={selection}
+        defaultQuery=""
+        query={decodeScalar(query.query, '')}
+        onSearch={searchQuery => {
+          browserHistory.push({
+            pathname,
+            query: {
+              ...query,
+              cursor: undefined,
+              query: searchQuery.trim(),
+            },
+          });
+        }}
+      />
+    </SearchContainer>
+  );
+}
+
+const SearchContainer = styled('div')`
+  display: grid;
+  width: 100%;
+`;