Browse Source

feat(replays): add click search feature banner (#49708)

## Summary 

Closes: https://github.com/getsentry/team-replay/issues/79

Adds the new feature banner to replay click search.

**Banner + SDK Support**

![image](https://github.com/getsentry/sentry/assets/7349258/49246c3d-ada7-41a2-8523-a40aa524a05c)


**Clicking Try Now**
Clicking try now adds `click.tag:button` as a search and dismisses
banner

![image](https://github.com/getsentry/sentry/assets/7349258/77eeb42c-ea16-441b-9c45-1798b3552524)



**Banner + No SDK Support**

![image](https://github.com/getsentry/sentry/assets/7349258/50cb609c-56cb-4f78-95bc-8a6c41fab77e)

**Modal**
Note: I've omitted the screen grab for now. From what i've seen we don't
really upload screenshots of the app and present them, most everything
we display thats image like is simply an svg.

![image](https://github.com/getsentry/sentry/assets/7349258/3d1045d6-7b1a-47ef-a959-263ba5f6044b)


**Replay List empty state w/ click search & unsupported sdk**

![image](https://github.com/getsentry/sentry/assets/7349258/afec856f-2b2b-4f33-8f37-51b28b1114a2)

---------

Co-authored-by: Ryan Albrecht <ryan.albrecht@sentry.io>
Elias Hussary 1 year ago
parent
commit
4964076507

+ 110 - 0
static/app/components/replays/replayNewFeatureBanner.tsx

@@ -0,0 +1,110 @@
+import {CSSProperties} 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';
+import {IconBroadcast, 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;
+  onDismiss?: () => void;
+}
+
+export function ReplayNewFeatureBanner({heading, description, button, onDismiss}: Props) {
+  return (
+    <Wrapper>
+      {onDismiss && (
+        <CloseButton
+          onClick={onDismiss}
+          icon={<IconClose size="xs" />}
+          aria-label={t('Feature banner close')}
+          size="xs"
+        />
+      )}
+      <Background />
+      <Stack>
+        <SubText uppercase fontWeight={500}>
+          <IconBroadcast />
+          <span>{t('Whats New')}</span>
+        </SubText>
+        <TextContainer>
+          <h4>{heading}</h4>
+          <SubText>{description}</SubText>
+        </TextContainer>
+      </Stack>
+      {button}
+    </Wrapper>
+  );
+}
+
+const Wrapper = styled(Panel)`
+  display: flex;
+  padding: ${space(2)};
+  min-height: 100px;
+  justify-content: space-between;
+  align-items: center;
+`;
+
+const CloseButton = styled(Button)`
+  justify-content: center;
+  position: absolute;
+  top: -${space(1)};
+  right: -${space(1)};
+  border-radius: 50%;
+  height: ${p => p.theme.iconSizes.lg};
+  width: ${p => p.theme.iconSizes.lg};
+  z-index: 1;
+`;
+
+const Background = styled('div')`
+  display: flex;
+  justify-self: flex-end;
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  height: 100%;
+  width: 50%;
+  max-width: 500px;
+  background-image: url(${newFeatureImage});
+  background-repeat: no-repeat;
+  background-size: cover;
+`;
+
+const Stack = styled('div')`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  max-width: 50%;
+  gap: ${space(1)};
+`;
+
+const TextContainer = styled('div')`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  z-index: 1;
+  h4 {
+    margin-bottom: ${space(0.5)};
+  }
+`;
+
+const SubText = styled('div')<{
+  fontSize?: 'sm';
+  fontWeight?: CSSProperties['fontWeight'];
+  uppercase?: boolean;
+}>`
+  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)};
+`;

+ 72 - 10
static/app/utils/useProjectSdkNeedsUpdate.spec.tsx

@@ -9,15 +9,21 @@ const mockUseProjectSdkUpdates = useProjectSdkUpdates as jest.MockedFunction<
   typeof useProjectSdkUpdates
 >;
 
-function mockCurrentVersion(currentVersion: string) {
+function mockCurrentVersion(
+  mockUpdates: Array<{
+    projectId: string;
+    sdkVersion: string;
+  }>
+) {
   mockUseProjectSdkUpdates.mockReturnValue({
     type: 'resolved',
-    data: {
-      projectId: TestStubs.Project().id,
+    // @ts-expect-error the return type is overloaded and ts seems to want the first return type of ProjectSdkUpdate
+    data: mockUpdates.map(({projectId, sdkVersion}) => ({
+      projectId,
       sdkName: 'javascript',
-      sdkVersion: currentVersion,
+      sdkVersion,
       suggestions: [],
-    },
+    })),
   });
 }
 describe('useProjectSdkNeedsUpdate', () => {
@@ -30,7 +36,7 @@ describe('useProjectSdkNeedsUpdate', () => {
       initialProps: {
         minVersion: '1.0.0',
         organization: TestStubs.Organization(),
-        projectId: TestStubs.Project().id,
+        projectId: [TestStubs.Project().id],
       },
     });
     expect(result.current.isFetching).toBeTruthy();
@@ -38,13 +44,18 @@ describe('useProjectSdkNeedsUpdate', () => {
   });
 
   it('should not need an update if the sdk version is above the min version', () => {
-    mockCurrentVersion('3.0.0');
+    mockCurrentVersion([
+      {
+        projectId: TestStubs.Project().id,
+        sdkVersion: '3.0.0',
+      },
+    ]);
 
     const {result} = reactHooks.renderHook(useProjectSdkNeedsUpdate, {
       initialProps: {
         minVersion: '1.0.0',
         organization: TestStubs.Organization(),
-        projectId: TestStubs.Project().id,
+        projectId: [TestStubs.Project().id],
       },
     });
     expect(result.current.isFetching).toBeFalsy();
@@ -52,16 +63,67 @@ describe('useProjectSdkNeedsUpdate', () => {
   });
 
   it('should be updated it the sdk version is too low', () => {
-    mockCurrentVersion('3.0.0');
+    mockCurrentVersion([
+      {
+        projectId: TestStubs.Project().id,
+        sdkVersion: '3.0.0',
+      },
+    ]);
 
     const {result} = reactHooks.renderHook(useProjectSdkNeedsUpdate, {
       initialProps: {
         minVersion: '8.0.0',
         organization: TestStubs.Organization(),
-        projectId: TestStubs.Project().id,
+        projectId: [TestStubs.Project().id],
       },
     });
     expect(result.current.isFetching).toBeFalsy();
     expect(result.current.needsUpdate).toBeTruthy();
   });
+
+  it('should return needsUpdate if multiple projects', () => {
+    mockCurrentVersion([
+      {
+        projectId: '1',
+        sdkVersion: '3.0.0',
+      },
+      {
+        projectId: '2',
+        sdkVersion: '3.0.0',
+      },
+    ]);
+
+    const {result} = reactHooks.renderHook(useProjectSdkNeedsUpdate, {
+      initialProps: {
+        minVersion: '8.0.0',
+        organization: TestStubs.Organization(),
+        projectId: ['1', '2'],
+      },
+    });
+    expect(result.current.isFetching).toBeFalsy();
+    expect(result.current.needsUpdate).toBeTruthy();
+  });
+
+  it('should not return needsUpdate if some projects meet minSdk', () => {
+    mockCurrentVersion([
+      {
+        projectId: '1',
+        sdkVersion: '8.0.0',
+      },
+      {
+        projectId: '2',
+        sdkVersion: '3.0.0',
+      },
+    ]);
+
+    const {result} = reactHooks.renderHook(useProjectSdkNeedsUpdate, {
+      initialProps: {
+        minVersion: '8.0.0',
+        organization: TestStubs.Organization(),
+        projectId: ['1', '2'],
+      },
+    });
+    expect(result.current.isFetching).toBeFalsy();
+    expect(result.current.needsUpdate).toBeFalsy();
+  });
 });

+ 13 - 4
static/app/utils/useProjectSdkNeedsUpdate.tsx

@@ -5,7 +5,7 @@ import {semverCompare} from 'sentry/utils/versions';
 type Opts = {
   minVersion: string;
   organization: Organization;
-  projectId: string;
+  projectId: string[];
 };
 
 function useProjectSdkNeedsUpdate({minVersion, organization, projectId}: Opts):
@@ -19,18 +19,27 @@ function useProjectSdkNeedsUpdate({minVersion, organization, projectId}: Opts):
     } {
   const sdkUpdates = useProjectSdkUpdates({
     organization,
-    projectId,
+    projectId: null,
   });
 
   if (sdkUpdates.type !== 'resolved') {
     return {isFetching: true, needsUpdate: undefined};
   }
 
-  if (!sdkUpdates.data?.sdkVersion) {
+  if (!sdkUpdates.data?.length) {
     return {isFetching: true, needsUpdate: undefined};
   }
 
-  const needsUpdate = semverCompare(sdkUpdates.data?.sdkVersion || '', minVersion) === -1;
+  const selectedProjects = sdkUpdates.data.filter(sdkUpdate =>
+    projectId.includes(sdkUpdate.projectId)
+  );
+
+  const needsUpdate =
+    selectedProjects.length > 0 &&
+    selectedProjects.every(
+      sdkUpdate => semverCompare(sdkUpdate.sdkVersion || '', minVersion) === -1
+    );
+
   return {isFetching: false, needsUpdate};
 }
 

+ 1 - 1
static/app/views/replays/detail/network/details/onboarding.tsx

@@ -127,7 +127,7 @@ export function Setup({
   const {isFetching, needsUpdate} = useProjectSdkNeedsUpdate({
     minVersion,
     organization,
-    projectId,
+    projectId: [projectId],
   });
   const sdkNeedsUpdate = !isFetching && needsUpdate;
 

+ 55 - 95
static/app/views/replays/list/replaySearchAlert.spec.tsx

@@ -1,132 +1,92 @@
-import {render, screen} from 'sentry-test/reactTestingLibrary';
+import {browserHistory} from 'react-router';
 
-import {PageFilters} from 'sentry/types';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import useDismissAlert from 'sentry/utils/useDismissAlert';
 import {useLocation} from 'sentry/utils/useLocation';
-import usePageFilters from 'sentry/utils/usePageFilters';
-import useProjects from 'sentry/utils/useProjects';
-import {useProjectSdkUpdates} from 'sentry/utils/useProjectSdkUpdates';
 import {ReplaySearchAlert} from 'sentry/views/replays/list/replaySearchAlert';
 
-jest.mock('sentry/utils/useProjects');
 jest.mock('sentry/utils/useLocation');
-jest.mock('sentry/utils/usePageFilters');
-jest.mock('sentry/utils/useProjectSdkUpdates');
+jest.mock('sentry/utils/useDismissAlert');
+jest.mock('react-router');
 
-const mockUseProjects = useProjects as jest.MockedFunction<typeof useProjects>;
-const mockUsePageFilters = usePageFilters as jest.MockedFunction<typeof usePageFilters>;
-const mockUseLocation = useLocation as jest.MockedFunction<typeof useLocation>;
-const mockUseProjectSdkUpdates = useProjectSdkUpdates as jest.MockedFunction<
-  typeof useProjectSdkUpdates
+const mockBrowserHistoryPush = browserHistory.push as jest.MockedFunction<
+  typeof browserHistory.push
+>;
+const mockUseDismissAlert = useDismissAlert as jest.MockedFunction<
+  typeof useDismissAlert
 >;
 
-const project = TestStubs.Project();
+const mockUseLocation = useLocation as jest.MockedFunction<typeof useLocation>;
 
 function getMockContext() {
   return TestStubs.routerContext([{}]);
 }
 
-function mockLocationReturn(query: string = ''): ReturnType<typeof useLocation> {
-  return {
-    query: {
-      query,
-    },
-    pathname: '',
-    search: '',
-    hash: '',
-    state: {},
-    action: 'PUSH',
-    key: '',
-  };
-}
-
 describe('ReplaySearchAlert', () => {
   beforeEach(() => {
-    mockUseProjects.mockReturnValue({
-      projects: [project],
-      fetching: false,
-      hasMore: false,
-      onSearch: () => Promise.resolve(),
-      fetchError: null,
-      initiallyLoaded: false,
-      placeholders: [],
+    mockUseDismissAlert.mockReturnValue({
+      dismiss: () => {},
+      isDismissed: false,
     });
-
-    mockUsePageFilters.mockReturnValue({
-      selection: {
-        // for some reason project.id selections are numbers, but elsewhere project.id is string
-        projects: [Number(project.id)],
-        datetime: {} as PageFilters['datetime'],
-        environments: [],
-      },
-      isReady: true,
-      shouldPersist: true,
-      desyncedFilters: new Set(),
-      pinnedFilters: new Set(),
+    mockUseLocation.mockReturnValue({
+      pathname: '',
+      query: {},
+      search: '',
+      key: '',
+      state: {},
+      action: 'PUSH',
+      hash: '',
     });
+  });
 
-    mockUseLocation.mockReturnValue(mockLocationReturn());
-
-    mockUseProjectSdkUpdates.mockReturnValue({
-      type: 'initial',
+  it('should render search alert by w/ Try Now CTA by default', () => {
+    const {container} = render(<ReplaySearchAlert needSdkUpdates={false} />, {
+      context: getMockContext(),
     });
+    expect(container).not.toBeEmptyDOMElement();
+    expect(container).toHaveTextContent('Try Now');
   });
 
-  it('should not render search alert by default', () => {
-    const {container} = render(<ReplaySearchAlert />, {
+  it('should render Learn More CTA if SDK requires update', () => {
+    const {container} = render(<ReplaySearchAlert needSdkUpdates />, {
       context: getMockContext(),
     });
-    expect(container).toBeEmptyDOMElement();
+
+    expect(container).toHaveTextContent('Learn More');
   });
 
-  it('should render dismissible alert if minSdk <= 7.44.0', () => {
-    mockUseProjectSdkUpdates.mockReturnValue({
-      type: 'resolved',
-      // @ts-expect-error - ts doesn't play nice with overloaded returns
-      data: [
-        {
-          projectId: project.id,
-          sdkName: 'javascript',
-          sdkVersion: '7.0.0',
-          suggestions: [],
-        },
-      ],
+  it('should push location.query and dismiss when clicking Try Now CTA', async () => {
+    const dismiss = jest.fn();
+    mockUseDismissAlert.mockReturnValue({
+      dismiss,
+      isDismissed: false,
     });
-
-    const {container} = render(<ReplaySearchAlert />, {
+    const {container} = render(<ReplaySearchAlert needSdkUpdates={false} />, {
       context: getMockContext(),
     });
-
-    expect(container).not.toBeEmptyDOMElement();
-    expect(screen.queryByTestId('min-sdk-alert')).toBeInTheDocument();
-    expect(container).toHaveTextContent(
-      "Search for dom elements clicked during a replay by using our new search key 'click'. Sadly, it requires an SDK version >= 7.44.0"
+    expect(container).toHaveTextContent('Try Now');
+    const tryNowButton = await screen.findByText('Try Now');
+    await userEvent.click(tryNowButton);
+    expect(dismiss).toHaveBeenCalled();
+    expect(mockBrowserHistoryPush).toHaveBeenCalledWith(
+      expect.objectContaining({
+        query: {
+          query: 'click.tag:button',
+        },
+      })
     );
   });
 
-  it('should render update alert if minSdk <= 7.44.0 and search contains "click" key', () => {
-    mockUseLocation.mockReturnValue(mockLocationReturn('click.alt:foo'));
-
-    mockUseProjectSdkUpdates.mockReturnValue({
-      type: 'resolved',
-      // @ts-expect-error - ts doesn't play nice with overloaded returns
-      data: [
-        {
-          projectId: project.id,
-          sdkName: 'javascript',
-          sdkVersion: '7.0.0',
-          suggestions: [],
-        },
-      ],
+  it('should render nothing if dismissed', () => {
+    mockUseDismissAlert.mockReturnValue({
+      dismiss: () => {},
+      isDismissed: true,
     });
 
-    const {container} = render(<ReplaySearchAlert />, {
+    const {container} = render(<ReplaySearchAlert needSdkUpdates={false} />, {
       context: getMockContext(),
     });
-
-    expect(container).not.toBeEmptyDOMElement();
-    expect(screen.queryByTestId('min-sdk-alert')).toBeInTheDocument();
-    expect(container).toHaveTextContent(
-      "Search field 'click' requires a minimum SDK version of >= 7.44.0."
-    );
+    expect(container).toBeEmptyDOMElement();
   });
 });

+ 96 - 98
static/app/views/replays/list/replaySearchAlert.tsx

@@ -1,133 +1,131 @@
-import {useMemo} from 'react';
+import {Fragment} from 'react';
+import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 
-import Alert from 'sentry/components/alert';
+import {ModalRenderProps, openModal} from 'sentry/actionCreators/modal';
 import {Button} from 'sentry/components/button';
-import {IconClose, IconInfo} from 'sentry/icons';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {ReplayNewFeatureBanner} from 'sentry/components/replays/replayNewFeatureBanner';
+import {IconBroadcast} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import type {Project, ProjectSdkUpdates} from 'sentry/types';
-import {decodeScalar} from 'sentry/utils/queryString';
-import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import useDismissAlert from 'sentry/utils/useDismissAlert';
 import {useLocation} from 'sentry/utils/useLocation';
-import useOrganization from 'sentry/utils/useOrganization';
-import usePageFilters from 'sentry/utils/usePageFilters';
-import useProjects from 'sentry/utils/useProjects';
-import {useProjectSdkUpdates} from 'sentry/utils/useProjectSdkUpdates';
-import {semverCompare} from 'sentry/utils/versions';
 
-const MIN_REPLAY_CLICK_SDK = '7.44.0';
-const LOCAL_STORAGE_KEY = 'replay-search-min-sdk-alert-dismissed';
+const REPLAY_CLICK_SEARCH_FEATURE_BANNER_KEY = 'new-feature-banner-replays-click-search';
+interface Props {
+  needSdkUpdates: boolean;
+}
 
-// exported for testing
-export function ReplaySearchAlert() {
-  const {selection} = usePageFilters();
-  const projects = useProjects();
+export function ReplaySearchAlert({needSdkUpdates}: Props) {
   const location = useLocation();
-  const organization = useOrganization();
-  const sdkUpdates = useProjectSdkUpdates({
-    organization,
-    projectId: null,
-  });
-
-  const {dismiss: handleDismiss, isDismissed} = useDismissAlert({
-    key: LOCAL_STORAGE_KEY,
+  const {dismiss, isDismissed} = useDismissAlert({
+    key: REPLAY_CLICK_SEARCH_FEATURE_BANNER_KEY,
   });
-  const conditions = useMemo(() => {
-    return new MutableSearch(decodeScalar(location.query.query, ''));
-  }, [location.query.query]);
 
-  const hasReplayClick = conditions.getFilterKeys().some(k => k.startsWith('click.'));
-
-  if (sdkUpdates.type !== 'resolved') {
+  if (isDismissed) {
     return null;
   }
+  const heading = (
+    <Fragment>
+      {tct('Introducing [feature]', {
+        feature: (
+          <ExternalLink href="https://blog.sentry.io/introducing-search-by-user-click-for-session-replay-zero-in-on-interesting/">
+            {t('Click Search')}
+          </ExternalLink>
+        ),
+      })}
+    </Fragment>
+  );
 
-  const selectedProjectsWithSdkUpdates = sdkUpdates.data?.reduce((acc, sdkUpdate) => {
-    if (!selection.projects.includes(Number(sdkUpdate.projectId))) {
-      return acc;
-    }
-
-    const project = projects.projects.find(p => p.id === sdkUpdate.projectId);
-    // should never really happen but making ts happy
-    if (!project) {
-      return acc;
-    }
+  const description = (
+    <span>
+      {tct(
+        `Find replays which captured specific DOM elements using our new search key [key]`,
+        {
+          key: <strong>{t("'click'")}</strong>,
+        }
+      )}
+    </span>
+  );
 
-    acc.push({
-      project,
-      sdkUpdate,
+  const handleTryNow = () => {
+    browserHistory.push({
+      ...location,
+      query: {
+        ...location.query,
+        query: 'click.tag:button',
+      },
     });
+    dismiss();
+  };
 
-    return acc;
-  }, [] as Array<{project: Project; sdkUpdate: ProjectSdkUpdates}>);
-
-  const doesNotMeetMinSDK =
-    selectedProjectsWithSdkUpdates &&
-    selectedProjectsWithSdkUpdates.length > 0 &&
-    selectedProjectsWithSdkUpdates.every(({sdkUpdate}) => {
-      return semverCompare(sdkUpdate.sdkVersion, MIN_REPLAY_CLICK_SDK) === -1;
-    });
+  const handleLearnMore = () => {
+    openModal(LearnMoreModal);
+  };
 
-  if (!doesNotMeetMinSDK) {
+  if (isDismissed) {
     return null;
   }
 
-  if (hasReplayClick) {
+  if (needSdkUpdates) {
     return (
-      <Alert data-test-id="min-sdk-alert">
-        <AlertContent>
-          <IconInfo />
-          <AlertText>
-            {tct(
-              'Search field [click] requires a minimum SDK version of >= [minSdkVersion].',
-              {
-                click: <strong>'click'</strong>,
-                minSdkVersion: <strong>{MIN_REPLAY_CLICK_SDK}</strong>,
-              }
-            )}
-          </AlertText>
-        </AlertContent>
-      </Alert>
+      <ReplayNewFeatureBanner
+        heading={heading}
+        description={description}
+        button={
+          <Button priority="primary" onClick={handleLearnMore}>
+            {t('Learn More')}
+          </Button>
+        }
+      />
     );
   }
 
-  if (isDismissed) {
-    return null;
-  }
+  return (
+    <ReplayNewFeatureBanner
+      heading={heading}
+      description={description}
+      button={
+        <Button priority="primary" onClick={handleTryNow}>
+          {t('Try Now')}
+        </Button>
+      }
+    />
+  );
+}
 
+function LearnMoreModal({Header, Body, Footer, closeModal}: ModalRenderProps) {
   return (
-    <Alert data-test-id="min-sdk-alert">
-      <AlertContent>
-        <IconInfo />
-        <AlertText>
-          {tct(
-            'Search for dom elements clicked during a replay by using our new search key [click]. Sadly, it requires an SDK version >= [version]',
-            {
-              click: <strong>{`'click'`}</strong>,
-              version: <strong>{MIN_REPLAY_CLICK_SDK}</strong>,
-            }
+    <Fragment>
+      <Header>
+        <ModalHeaderContainer>
+          <IconBroadcast />
+          <h2>{t('Click Search')}</h2>
+        </ModalHeaderContainer>
+      </Header>
+      <Body>
+        <p>
+          {t(
+            'Search by user click is a new feature which allows you to search for replays by DOM element - or in other words - where users have clicked to interact with specific parts of your web app.'
           )}
-        </AlertText>
-        <Button
-          priority="link"
-          size="sm"
-          icon={<IconClose size="xs" />}
-          aria-label={t('Close Alert')}
-          onClick={handleDismiss}
-        />
-      </AlertContent>
-    </Alert>
+        </p>
+        <strong>{t('Prerequisites')}</strong>
+        <ul>
+          <li>{t('JavaScript SDK Version is >= 7.44.0')}</li>
+        </ul>
+      </Body>
+      <Footer>
+        <Button priority="primary" onClick={closeModal}>
+          {t('Got it')}
+        </Button>
+      </Footer>
+    </Fragment>
   );
 }
 
-const AlertContent = styled('div')`
+const ModalHeaderContainer = styled('div')`
   display: flex;
-  justify-content: space-between;
+  align-items: center;
   gap: ${space(1)};
 `;
-
-const AlertText = styled('div')`
-  flex-grow: 1;
-`;

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

@@ -132,6 +132,6 @@ describe('ReplayList', () => {
     });
 
     await waitFor(() => expect(screen.getByTestId('replay-table')).toBeInTheDocument());
-    expect(mockUseReplayList).toHaveBeenCalledTimes(1);
+    expect(mockUseReplayList).toHaveBeenCalled();
   });
 });

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

@@ -1,8 +1,10 @@
 import {Fragment, useMemo} from 'react';
 import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
 import {Location} from 'history';
 
 import Pagination from 'sentry/components/pagination';
+import {t, tct} from 'sentry/locale';
 import type {Organization} from 'sentry/types';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import EventView from 'sentry/utils/discover/eventView';
@@ -13,6 +15,8 @@ import {useHaveSelectedProjectsSentAnyReplayEvents} from 'sentry/utils/replays/h
 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 {ReplaySearchAlert} from 'sentry/views/replays/list/replaySearchAlert';
 import ReplayTable from 'sentry/views/replays/replayTable';
@@ -56,6 +60,8 @@ function ReplaysList() {
   );
 }
 
+const MIN_REPLAY_CLICK_SDK = '7.44.0';
+
 function ReplaysListTable({
   eventView,
   location,
@@ -71,9 +77,30 @@ function ReplaysListTable({
     organization,
   });
 
+  const {
+    selection: {projects},
+  } = usePageFilters();
+
+  const {needsUpdate: allSelectedProjectsNeedUpdates} = useProjectSdkNeedsUpdate({
+    minVersion: MIN_REPLAY_CLICK_SDK,
+    organization,
+    projectId: projects.map(p => String(p)),
+  });
+
+  const conditions = useMemo(() => {
+    return new MutableSearch(decodeScalar(location.query.query, ''));
+  }, [location.query.query]);
+
+  const hasReplayClick = conditions.getFilterKeys().some(k => k.startsWith('click.'));
+
+  const hasReplayClickSearchBannerRollout = organization.features.includes(
+    'session-replay-click-search-banner-rollout'
+  );
   return (
     <Fragment>
-      <ReplaySearchAlert />
+      {hasReplayClickSearchBannerRollout && (
+        <ReplaySearchAlert needSdkUpdates={Boolean(allSelectedProjectsNeedUpdates)} />
+      )}
       <ReplayTable
         fetchError={fetchError}
         isFetching={isFetching}
@@ -87,6 +114,19 @@ function ReplaysListTable({
           ReplayColumns.countErrors,
           ReplayColumns.activity,
         ]}
+        emptyMessage={
+          allSelectedProjectsNeedUpdates && hasReplayClick ? (
+            <Fragment>
+              {t('Unindexed search field')}
+              <EmptyStateSubheading>
+                {tct('Field [field] requires an [sdkPrompt]', {
+                  field: <strong>'click'</strong>,
+                  sdkPrompt: <strong>{t('SDK version >= 7.44.0')}</strong>,
+                })}
+              </EmptyStateSubheading>
+            </Fragment>
+          ) : undefined
+        }
       />
       <Pagination
         pageLinks={pageLinks}
@@ -105,4 +145,9 @@ function ReplaysListTable({
   );
 }
 
+const EmptyStateSubheading = styled('div')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
 export default ReplaysList;

+ 11 - 2
static/app/views/replays/replayTable/index.tsx

@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {Fragment, ReactNode} from 'react';
 import styled from '@emotion/styled';
 
 import {Alert} from 'sentry/components/alert';
@@ -30,9 +30,17 @@ type Props = {
   replays: undefined | ReplayListRecord[] | ReplayListRecordWithTx[];
   sort: Sort | undefined;
   visibleColumns: Array<keyof typeof ReplayColumns>;
+  emptyMessage?: ReactNode;
 };
 
-function ReplayTable({fetchError, isFetching, replays, sort, visibleColumns}: Props) {
+function ReplayTable({
+  fetchError,
+  isFetching,
+  replays,
+  sort,
+  visibleColumns,
+  emptyMessage,
+}: Props) {
   const routes = useRoutes();
   const location = useLocation();
   const organization = useOrganization();
@@ -71,6 +79,7 @@ function ReplayTable({fetchError, isFetching, replays, sort, visibleColumns}: Pr
       visibleColumns={visibleColumns}
       disablePadding
       data-test-id="replay-table"
+      emptyMessage={emptyMessage}
     >
       {replays?.map(replay => {
         return (

File diff suppressed because it is too large
+ 73 - 0
static/images/spot/alerts-new-feature-banner.svg


Some files were not shown because too many files changed in this diff