Browse Source

feat(replay): Create a Replay Details>Errors tab to replace Issues (#50701)

New Errors tab, replaces Issues

<img width="736" alt="SCR-20230609-oakw"
src="https://github.com/getsentry/sentry/assets/187460/9faf76f5-8c44-495b-9321-2a7231467565">

Per figma design:
https://www.figma.com/file/VJffxPWvRaaRJ1gywzuUre/Specs%3A-Connecting-Backend-Errors?type=design&node-id=422-25593&t=ItjQdTp713gEnnYn-0

There are tooltips as well:
| Title | Issue Id | 
| --- | --- |
| <img width="645" alt="SCR-20230613-hwez"
src="https://github.com/getsentry/sentry/assets/187460/bc41c9e9-f946-47d2-ab56-798e2e755a99">
| <img width="741" alt="SCR-20230609-oaql"
src="https://github.com/getsentry/sentry/assets/187460/48f6cbb0-3b1a-4949-8ad5-7cf8659858c0">
|


Fixes https://github.com/getsentry/sentry/issues/50306
Ryan Albrecht 1 year ago
parent
commit
8895162156

+ 60 - 1
static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx

@@ -5,11 +5,16 @@ import {reactHooks} from 'sentry-test/reactTestingLibrary';
 
 import useActiveReplayTab, {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
 import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
 
 jest.mock('react-router');
 jest.mock('sentry/utils/useLocation');
+jest.mock('sentry/utils/useOrganization');
 
 const mockUseLocation = useLocation as jest.MockedFunction<typeof useLocation>;
+const mockUseOrganization = useOrganization as jest.MockedFunction<
+  typeof useOrganization
+>;
 const mockPush = browserHistory.push as jest.MockedFunction<typeof browserHistory.push>;
 
 function mockLocation(query: string = '') {
@@ -24,9 +29,19 @@ function mockLocation(query: string = '') {
   } as Location);
 }
 
+function mockOrganization(props?: {features: string[]}) {
+  const features = props?.features ?? [];
+  mockUseOrganization.mockReturnValue(
+    TestStubs.Organization({
+      features,
+    })
+  );
+}
+
 describe('useActiveReplayTab', () => {
   beforeEach(() => {
     mockLocation();
+    mockOrganization();
     mockPush.mockReset();
   });
 
@@ -55,7 +70,7 @@ describe('useActiveReplayTab', () => {
     });
   });
 
-  it('should not change the tab if the name is invalid', () => {
+  it('should set the default tab if the name is invalid', () => {
     const {result} = reactHooks.renderHook(useActiveReplayTab);
     expect(result.current.getActiveTab()).toBe(TabKey.CONSOLE);
 
@@ -65,4 +80,48 @@ describe('useActiveReplayTab', () => {
       query: {t_main: TabKey.CONSOLE},
     });
   });
+
+  it('should allow ISSUES if session-replay-errors-tab is disabled', () => {
+    mockOrganization({
+      features: [],
+    });
+    const {result} = reactHooks.renderHook(useActiveReplayTab);
+    expect(result.current.getActiveTab()).toBe(TabKey.CONSOLE);
+
+    // ISSUES is allowed:
+    result.current.setActiveTab(TabKey.ISSUES);
+    expect(mockPush).toHaveBeenLastCalledWith({
+      pathname: '',
+      query: {t_main: TabKey.ISSUES},
+    });
+
+    // ERRORS is not enabled, reset to default:
+    result.current.setActiveTab(TabKey.ERRORS);
+    expect(mockPush).toHaveBeenLastCalledWith({
+      pathname: '',
+      query: {t_main: TabKey.CONSOLE},
+    });
+  });
+
+  it('should allow ERRORS if session-replay-errors-tab is enabled', () => {
+    mockOrganization({
+      features: ['session-replay-errors-tab'],
+    });
+    const {result} = reactHooks.renderHook(useActiveReplayTab);
+    expect(result.current.getActiveTab()).toBe(TabKey.CONSOLE);
+
+    // ERRORS is enabled:
+    result.current.setActiveTab(TabKey.ERRORS);
+    expect(mockPush).toHaveBeenLastCalledWith({
+      pathname: '',
+      query: {t_main: TabKey.ERRORS},
+    });
+
+    // ISSUES is not allowed, it's been replaced by ERRORS, reset to default:
+    result.current.setActiveTab(TabKey.ISSUES);
+    expect(mockPush).toHaveBeenLastCalledWith({
+      pathname: '',
+      query: {t_main: TabKey.CONSOLE},
+    });
+  });
 });

+ 22 - 7
static/app/utils/replays/hooks/useActiveReplayTab.tsx

@@ -1,20 +1,32 @@
 import {useCallback, useMemo} from 'react';
 
 import {parseSearch} from 'sentry/components/searchSyntax/parser';
+import type {Organization} from 'sentry/types';
 import {decodeScalar} from 'sentry/utils/queryString';
 import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
 import useUrlParams from 'sentry/utils/useUrlParams';
 
 export enum TabKey {
   CONSOLE = 'console',
   DOM = 'dom',
-  NETWORK = 'network',
-  TRACE = 'trace',
+  ERRORS = 'errors',
   ISSUES = 'issues',
   MEMORY = 'memory',
+  NETWORK = 'network',
+  TRACE = 'trace',
 }
 
-function isReplayTab(tab: string): tab is TabKey {
+function isReplayTab(tab: string, organization: Organization): tab is TabKey {
+  const hasErrorTab = organization.features.includes('session-replay-errors-tab');
+  if (tab === TabKey.ERRORS) {
+    // If the errors tab feature is enabled, then TabKey.ERRORS is valid.
+    return hasErrorTab;
+  }
+  if (tab === TabKey.ISSUES) {
+    // If the errors tab is enabled, then then Issues tab is invalid
+    return !hasErrorTab;
+  }
   return Object.values<string>(TabKey).includes(tab);
 }
 
@@ -37,22 +49,25 @@ function useDefaultTab() {
 
 function useActiveReplayTab() {
   const defaultTab = useDefaultTab();
+  const organization = useOrganization();
   const {getParamValue, setParamValue} = useUrlParams('t_main', defaultTab);
 
   const paramValue = getParamValue()?.toLowerCase() ?? '';
 
   return {
     getActiveTab: useCallback(
-      () => (isReplayTab(paramValue) ? (paramValue as TabKey) : defaultTab),
-      [paramValue, defaultTab]
+      () => (isReplayTab(paramValue, organization) ? (paramValue as TabKey) : defaultTab),
+      [organization, paramValue, defaultTab]
     ),
     setActiveTab: useCallback(
       (value: string) => {
         setParamValue(
-          isReplayTab(value.toLowerCase()) ? value.toLowerCase() : defaultTab
+          isReplayTab(value.toLowerCase(), organization)
+            ? value.toLowerCase()
+            : defaultTab
         );
       },
-      [setParamValue, defaultTab]
+      [organization, setParamValue, defaultTab]
     ),
   };
 }

+ 10 - 1
static/app/utils/replays/hooks/useCrumbHandlers.tsx

@@ -4,9 +4,11 @@ import {useReplayContext} from 'sentry/components/replays/replayContext';
 import {relativeTimeInMs} from 'sentry/components/replays/utils';
 import {BreadcrumbType, Crumb} from 'sentry/types/breadcrumbs';
 import useActiveReplayTab from 'sentry/utils/replays/hooks/useActiveReplayTab';
+import useOrganization from 'sentry/utils/useOrganization';
 import type {NetworkSpan} from 'sentry/views/replays/types';
 
 function useCrumbHandlers(startTimestampMs: number = 0) {
+  const organization = useOrganization();
   const {
     clearAllHighlights,
     highlight,
@@ -16,6 +18,8 @@ function useCrumbHandlers(startTimestampMs: number = 0) {
   } = useReplayContext();
   const {setActiveTab} = useActiveReplayTab();
 
+  const hasErrorTab = organization.features.includes('session-replay-errors-tab');
+
   const mouseEnterCallback = useRef<{
     id: string | number | null;
     timeoutId: NodeJS.Timeout | null;
@@ -87,6 +91,11 @@ function useCrumbHandlers(startTimestampMs: number = 0) {
       }
 
       if ('type' in crumb) {
+        if (hasErrorTab && crumb.type === BreadcrumbType.ERROR) {
+          setActiveTab('errors');
+          return;
+        }
+
         switch (crumb.type) {
           case BreadcrumbType.NAVIGATION:
             setActiveTab('network');
@@ -100,7 +109,7 @@ function useCrumbHandlers(startTimestampMs: number = 0) {
         }
       }
     },
-    [setCurrentTime, startTimestampMs, setActiveTab]
+    [setCurrentTime, startTimestampMs, setActiveTab, hasErrorTab]
   );
 
   return {

+ 5 - 1
static/app/utils/replays/replayReader.tsx

@@ -146,11 +146,15 @@ export default class ReplayReader {
   });
 
   getConsoleCrumbs = memoize(() =>
-    this.breadcrumbs.filter(crumb => ['console', 'issue'].includes(crumb.category || ''))
+    this.breadcrumbs.filter(crumb => crumb.category === 'console')
   );
 
   getRawErrors = memoize(() => this.rawErrors);
 
+  getIssueCrumbs = memoize(() =>
+    this.breadcrumbs.filter(crumb => crumb.category === 'issue')
+  );
+
   getNonConsoleCrumbs = memoize(() =>
     this.breadcrumbs.filter(crumb => crumb.category !== 'console')
   );

+ 4 - 1
static/app/views/replays/detail/console/viewIssueLink.tsx

@@ -1,6 +1,7 @@
 import ShortId from 'sentry/components/shortId';
 import {BreadcrumbTypeDefault, Crumb} from 'sentry/types/breadcrumbs';
 import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import {breadcrumbHasIssue} from 'sentry/views/replays/detail/console/utils';
 
 type Props = {
@@ -16,7 +17,9 @@ function ViewIssueLink({breadcrumb}: Props) {
   const {groupId, groupShortId, eventId} = breadcrumb.data || {};
 
   const to = {
-    pathname: `/organizations/${organization.slug}/issues/${groupId}/events/${eventId}/?referrer=replay-console`,
+    pathname: normalizeUrl(
+      `/organizations/${organization.slug}/issues/${groupId}/events/${eventId}/?referrer=replay-console`
+    ),
   };
   return <ShortId to={to} shortId={groupShortId} />;
 }

+ 45 - 0
static/app/views/replays/detail/errorList/errorFilters.tsx

@@ -0,0 +1,45 @@
+import {CompactSelect, SelectOption} from 'sentry/components/compactSelect';
+import SearchBar from 'sentry/components/searchBar';
+import {t} from 'sentry/locale';
+import type {Crumb} from 'sentry/types/breadcrumbs';
+import useErrorFilters from 'sentry/views/replays/detail/errorList/useErrorFilters';
+import FiltersGrid from 'sentry/views/replays/detail/filtersGrid';
+
+type Props = {
+  errorCrumbs: undefined | Crumb[];
+} & ReturnType<typeof useErrorFilters>;
+
+function ErrorFilters({
+  getProjectOptions,
+  errorCrumbs,
+  searchTerm,
+  selectValue,
+  setFilters,
+  setSearchTerm,
+}: Props) {
+  const projectOptions = getProjectOptions();
+
+  return (
+    <FiltersGrid>
+      <CompactSelect
+        disabled={!projectOptions.length}
+        multiple
+        onChange={setFilters as (selection: SelectOption<string>[]) => void}
+        options={projectOptions}
+        size="sm"
+        triggerLabel={selectValue?.length === 0 ? t('Any') : null}
+        triggerProps={{prefix: t('Project')}}
+        value={selectValue}
+      />
+      <SearchBar
+        size="sm"
+        onChange={setSearchTerm}
+        placeholder={t('Search Errors')}
+        query={searchTerm}
+        disabled={!errorCrumbs || !errorCrumbs.length}
+      />
+    </FiltersGrid>
+  );
+}
+
+export default ErrorFilters;

+ 85 - 0
static/app/views/replays/detail/errorList/errorHeaderCell.tsx

@@ -0,0 +1,85 @@
+import {ComponentProps, CSSProperties, forwardRef, ReactNode} from 'react';
+import styled from '@emotion/styled';
+
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconArrow, IconInfo} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import useSortErrors from 'sentry/views/replays/detail/errorList/useSortErrors';
+
+type SortConfig = ReturnType<typeof useSortErrors>['sortConfig'];
+type Props = {
+  handleSort: ReturnType<typeof useSortErrors>['handleSort'];
+  index: number;
+  sortConfig: SortConfig;
+  style: CSSProperties;
+};
+
+const SizeInfoIcon = styled(IconInfo)`
+  display: block;
+`;
+
+const COLUMNS: {
+  field: SortConfig['by'];
+  label: string;
+  tooltipTitle?: ComponentProps<typeof Tooltip>['title'];
+}[] = [
+  {field: 'id', label: t('Event ID')},
+  {field: 'title', label: t('Title')},
+  {field: 'project', label: t('Issue')},
+  {field: 'timestamp', label: t('Timestamp')},
+];
+
+export const COLUMN_COUNT = COLUMNS.length;
+
+function CatchClicks({children}: {children: ReactNode}) {
+  return <div onClick={e => e.stopPropagation()}>{children}</div>;
+}
+
+const ErrorHeaderCell = forwardRef<HTMLButtonElement, Props>(
+  ({handleSort, index, sortConfig, style}: Props, ref) => {
+    const {field, label, tooltipTitle} = COLUMNS[index];
+    return (
+      <HeaderButton style={style} onClick={() => handleSort(field)} ref={ref}>
+        {label}
+        {tooltipTitle ? (
+          <Tooltip isHoverable title={<CatchClicks>{tooltipTitle}</CatchClicks>}>
+            <SizeInfoIcon size="xs" />
+          </Tooltip>
+        ) : null}
+        <IconArrow
+          color="gray300"
+          size="xs"
+          direction={sortConfig.by === field && !sortConfig.asc ? 'down' : 'up'}
+          style={{visibility: sortConfig.by === field ? 'visible' : 'hidden'}}
+        />
+      </HeaderButton>
+    );
+  }
+);
+
+const HeaderButton = styled('button')`
+  border: 0;
+  border-bottom: 1px solid ${p => p.theme.border};
+  background: ${p => p.theme.backgroundSecondary};
+  color: ${p => p.theme.subText};
+
+  font-size: ${p => p.theme.fontSizeSmall};
+  font-weight: 600;
+  line-height: 16px;
+  text-align: unset;
+  text-transform: uppercase;
+  white-space: nowrap;
+
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: ${space(0.5)} ${space(1)} ${space(0.5)} ${space(1.5)};
+
+  svg {
+    margin-left: ${space(0.25)};
+  }
+`;
+
+export default ErrorHeaderCell;

+ 235 - 0
static/app/views/replays/detail/errorList/errorTableCell.tsx

@@ -0,0 +1,235 @@
+import {CSSProperties, forwardRef, useMemo} from 'react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+
+import Avatar from 'sentry/components/avatar';
+import Link from 'sentry/components/links/link';
+import {relativeTimeInMs} from 'sentry/components/replays/utils';
+import {space} from 'sentry/styles/space';
+import type {Crumb} from 'sentry/types/breadcrumbs';
+import {getShortEventId} from 'sentry/utils/events';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {QuickContextHoverWrapper} from 'sentry/views/discover/table/quickContext/quickContextWrapper';
+import {ContextType} from 'sentry/views/discover/table/quickContext/utils';
+import useSortErrors from 'sentry/views/replays/detail/errorList/useSortErrors';
+import TimestampButton from 'sentry/views/replays/detail/timestampButton';
+
+const EMPTY_CELL = '--';
+
+type Props = {
+  columnIndex: number;
+  crumb: Crumb;
+  currentHoverTime: number | undefined;
+  currentTime: number;
+  handleMouseEnter: (crumb: Crumb) => void;
+  handleMouseLeave: (crumb: Crumb) => void;
+  onClickTimestamp: (crumb: Crumb) => void;
+  rowIndex: number;
+  sortConfig: ReturnType<typeof useSortErrors>['sortConfig'];
+  startTimestampMs: number;
+  style: CSSProperties;
+};
+
+type CellProps = {
+  hasOccurred: boolean | undefined;
+  align?: 'flex-start' | 'flex-end';
+  className?: string;
+  gap?: Parameters<typeof space>[0];
+  numeric?: boolean;
+  onClick?: undefined | (() => void);
+};
+
+const ErrorTableCell = forwardRef<HTMLDivElement, Props>(
+  (
+    {
+      columnIndex,
+      currentHoverTime,
+      currentTime,
+      handleMouseEnter,
+      handleMouseLeave,
+      onClickTimestamp,
+      sortConfig,
+      crumb,
+      startTimestampMs,
+      style,
+    }: Props,
+    ref
+  ) => {
+    const organization = useOrganization();
+
+    // @ts-expect-error
+    const {eventId, groupId, groupShortId, project: projectSlug} = crumb.data;
+    const title = crumb.message;
+    const {projects} = useProjects();
+    const project = useMemo(
+      () => projects.find(p => p.slug === projectSlug),
+      [projects, projectSlug]
+    );
+
+    const issueUrl =
+      groupId && eventId
+        ? {
+            pathname: normalizeUrl(
+              `/organizations/${organization.slug}/issues/${groupId}/events/${eventId}/`
+            ),
+            query: {
+              referrer: 'replay-errors',
+            },
+          }
+        : null;
+
+    const crumbTime = useMemo(
+      // @ts-expect-error
+      () => relativeTimeInMs(new Date(crumb.timestamp).getTime(), startTimestampMs),
+      [crumb.timestamp, startTimestampMs]
+    );
+    const hasOccurred = currentTime >= crumbTime;
+    const isBeforeHover = currentHoverTime === undefined || currentHoverTime >= crumbTime;
+
+    const isByTimestamp = sortConfig.by === 'timestamp';
+    const isAsc = isByTimestamp ? sortConfig.asc : undefined;
+    const columnProps = {
+      className: classNames({
+        beforeCurrentTime: isByTimestamp
+          ? isAsc
+            ? hasOccurred
+            : !hasOccurred
+          : undefined,
+        afterCurrentTime: isByTimestamp
+          ? isAsc
+            ? !hasOccurred
+            : hasOccurred
+          : undefined,
+        beforeHoverTime:
+          isByTimestamp && currentHoverTime !== undefined
+            ? isAsc
+              ? isBeforeHover
+              : !isBeforeHover
+            : undefined,
+        afterHoverTime:
+          isByTimestamp && currentHoverTime !== undefined
+            ? isAsc
+              ? !isBeforeHover
+              : isBeforeHover
+            : undefined,
+      }),
+      hasOccurred: isByTimestamp ? hasOccurred : undefined,
+      onMouseEnter: () => handleMouseEnter(crumb),
+      onMouseLeave: () => handleMouseLeave(crumb),
+      ref,
+      style,
+    } as CellProps;
+
+    const renderFns = [
+      () => (
+        <Cell {...columnProps} numeric align="flex-start">
+          {issueUrl ? (
+            <Link to={issueUrl}>
+              <Text>{getShortEventId(eventId || '')}</Text>
+            </Link>
+          ) : (
+            <Text>{getShortEventId(eventId || '')}</Text>
+          )}
+        </Cell>
+      ),
+      () => (
+        <Cell {...columnProps}>
+          <QuickContextHoverWrapper
+            dataRow={{
+              id: eventId,
+              'project.name': projectSlug,
+            }}
+            contextType={ContextType.EVENT}
+            organization={organization}
+          >
+            <Text>{title ?? EMPTY_CELL}</Text>
+          </QuickContextHoverWrapper>
+        </Cell>
+      ),
+      () => (
+        <Cell {...columnProps} gap={0.5}>
+          <AvatarWrapper>
+            <Avatar project={project} size={16} />
+          </AvatarWrapper>
+          <QuickContextHoverWrapper
+            dataRow={{
+              'issue.id': groupId,
+              issue: groupShortId,
+            }}
+            contextType={ContextType.ISSUE}
+            organization={organization}
+          >
+            {issueUrl ? (
+              <Link to={issueUrl}>{groupShortId}</Link>
+            ) : (
+              <span>{groupShortId}</span>
+            )}
+          </QuickContextHoverWrapper>
+        </Cell>
+      ),
+      () => (
+        <Cell {...columnProps} numeric>
+          <StyledTimestampButton
+            format="mm:ss.SSS"
+            onClick={() => {
+              onClickTimestamp(crumb);
+            }}
+            startTimestampMs={startTimestampMs}
+            timestampMs={crumb.timestamp || ''}
+          />
+        </Cell>
+      ),
+    ];
+
+    return renderFns[columnIndex]();
+  }
+);
+
+const cellBackground = p => {
+  if (p.hasOccurred === undefined && !p.isStatusError) {
+    const color = p.isHovered ? p.theme.hover : 'inherit';
+    return `background-color: ${color};`;
+  }
+  return `background-color: inherit;`;
+};
+
+const cellColor = p => {
+  return `color: ${p.hasOccurred !== false ? 'inherit' : p.theme.gray300};`;
+};
+
+const Cell = styled('div')<CellProps>`
+  display: flex;
+  gap: ${p => space(p.gap ?? 0)};
+  align-items: center;
+  font-size: ${p => p.theme.fontSizeSmall};
+  cursor: ${p => (p.onClick ? 'pointer' : 'inherit')};
+
+  ${cellBackground}
+  ${cellColor}
+
+  ${p =>
+    p.numeric &&
+    `
+    font-variant-numeric: tabular-nums;
+    justify-content: ${p.align ?? 'flex-end'};
+  `};
+`;
+
+const Text = styled('div')`
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  overflow: hidden;
+  padding: ${space(0.75)} ${space(1.5)};
+`;
+
+const AvatarWrapper = styled('div')`
+  align-self: center;
+`;
+
+const StyledTimestampButton = styled(TimestampButton)`
+  padding-inline: ${space(1.5)};
+`;
+
+export default ErrorTableCell;

+ 195 - 0
static/app/views/replays/detail/errorList/index.tsx

@@ -0,0 +1,195 @@
+import {useMemo, useRef} from 'react';
+import {AutoSizer, CellMeasurer, GridCellProps, MultiGrid} from 'react-virtualized';
+import styled from '@emotion/styled';
+
+import Placeholder from 'sentry/components/placeholder';
+import {useReplayContext} from 'sentry/components/replays/replayContext';
+import {t} from 'sentry/locale';
+import type {Crumb} from 'sentry/types/breadcrumbs';
+import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
+import ErrorFilters from 'sentry/views/replays/detail/errorList/errorFilters';
+import ErrorHeaderCell, {
+  COLUMN_COUNT,
+} from 'sentry/views/replays/detail/errorList/errorHeaderCell';
+import ErrorTableCell from 'sentry/views/replays/detail/errorList/errorTableCell';
+import useErrorFilters from 'sentry/views/replays/detail/errorList/useErrorFilters';
+import useSortErrors from 'sentry/views/replays/detail/errorList/useSortErrors';
+import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
+import NoRowRenderer from 'sentry/views/replays/detail/noRowRenderer';
+import useVirtualizedGrid from 'sentry/views/replays/detail/useVirtualizedGrid';
+
+const HEADER_HEIGHT = 25;
+const BODY_HEIGHT = 28;
+
+type Props = {
+  errorCrumbs: undefined | Crumb[];
+  startTimestampMs: number;
+};
+
+const cellMeasurer = {
+  defaultHeight: BODY_HEIGHT,
+  defaultWidth: 100,
+  fixedHeight: true,
+};
+
+function ErrorList({errorCrumbs, startTimestampMs}: Props) {
+  const {currentTime, currentHoverTime} = useReplayContext();
+
+  const filterProps = useErrorFilters({errorCrumbs: errorCrumbs || []});
+  const {items: filteredItems, searchTerm, setSearchTerm} = filterProps;
+  const clearSearchTerm = () => setSearchTerm('');
+  const {handleSort, items, sortConfig} = useSortErrors({items: filteredItems});
+
+  const {handleMouseEnter, handleMouseLeave, handleClick} =
+    useCrumbHandlers(startTimestampMs);
+
+  const gridRef = useRef<MultiGrid>(null);
+  const deps = useMemo(() => [items, searchTerm], [items, searchTerm]);
+  const {cache, getColumnWidth, onScrollbarPresenceChange, onWrapperResize} =
+    useVirtualizedGrid({
+      cellMeasurer,
+      gridRef,
+      columnCount: COLUMN_COUNT,
+      dynamicColumnIndex: 1,
+      deps,
+    });
+
+  const cellRenderer = ({columnIndex, rowIndex, key, style, parent}: GridCellProps) => {
+    const error = items[rowIndex - 1];
+
+    return (
+      <CellMeasurer
+        cache={cache}
+        columnIndex={columnIndex}
+        key={key}
+        parent={parent}
+        rowIndex={rowIndex}
+      >
+        {({
+          measure: _,
+          registerChild,
+        }: {
+          measure: () => void;
+          registerChild?: (element?: Element) => void;
+        }) =>
+          rowIndex === 0 ? (
+            <ErrorHeaderCell
+              ref={e => e && registerChild?.(e)}
+              handleSort={handleSort}
+              index={columnIndex}
+              sortConfig={sortConfig}
+              style={{...style, height: HEADER_HEIGHT}}
+            />
+          ) : (
+            <ErrorTableCell
+              columnIndex={columnIndex}
+              currentHoverTime={currentHoverTime}
+              currentTime={currentTime}
+              handleMouseEnter={handleMouseEnter}
+              handleMouseLeave={handleMouseLeave}
+              onClickTimestamp={handleClick}
+              ref={e => e && registerChild?.(e)}
+              rowIndex={rowIndex}
+              sortConfig={sortConfig}
+              crumb={error}
+              startTimestampMs={startTimestampMs}
+              style={{...style, height: BODY_HEIGHT}}
+            />
+          )
+        }
+      </CellMeasurer>
+    );
+  };
+
+  return (
+    <FluidHeight>
+      <ErrorFilters errorCrumbs={errorCrumbs} {...filterProps} />
+      <ErrorTable>
+        {errorCrumbs ? (
+          <OverflowHidden>
+            <AutoSizer onResize={onWrapperResize}>
+              {({height, width}) => (
+                <MultiGrid
+                  ref={gridRef}
+                  cellRenderer={cellRenderer}
+                  columnCount={COLUMN_COUNT}
+                  columnWidth={getColumnWidth(width)}
+                  deferredMeasurementCache={cache}
+                  estimatedColumnSize={100}
+                  estimatedRowSize={BODY_HEIGHT}
+                  fixedRowCount={1}
+                  height={height}
+                  noContentRenderer={() => (
+                    <NoRowRenderer
+                      unfilteredItems={errorCrumbs}
+                      clearSearchTerm={clearSearchTerm}
+                    >
+                      {t('No errors! Go make some.')}
+                    </NoRowRenderer>
+                  )}
+                  onScrollbarPresenceChange={onScrollbarPresenceChange}
+                  overscanColumnCount={COLUMN_COUNT}
+                  overscanRowCount={5}
+                  rowCount={items.length + 1}
+                  rowHeight={({index}) => (index === 0 ? HEADER_HEIGHT : BODY_HEIGHT)}
+                  width={width}
+                />
+              )}
+            </AutoSizer>
+          </OverflowHidden>
+        ) : (
+          <Placeholder height="100%" />
+        )}
+      </ErrorTable>
+    </FluidHeight>
+  );
+}
+
+const OverflowHidden = styled('div')`
+  position: relative;
+  height: 100%;
+  overflow: hidden;
+`;
+
+const ErrorTable = styled(FluidHeight)`
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${p => p.theme.borderRadius};
+
+  .beforeHoverTime + .afterHoverTime:before {
+    border-top: 1px solid ${p => p.theme.purple200};
+    content: '';
+    left: 0;
+    position: absolute;
+    top: 0;
+    width: 999999999%;
+  }
+
+  .beforeHoverTime:last-child:before {
+    border-bottom: 1px solid ${p => p.theme.purple200};
+    content: '';
+    right: 0;
+    position: absolute;
+    bottom: 0;
+    width: 999999999%;
+  }
+
+  .beforeCurrentTime + .afterCurrentTime:before {
+    border-top: 1px solid ${p => p.theme.purple300};
+    content: '';
+    left: 0;
+    position: absolute;
+    top: 0;
+    width: 999999999%;
+  }
+
+  .beforeCurrentTime:last-child:before {
+    border-bottom: 1px solid ${p => p.theme.purple300};
+    content: '';
+    right: 0;
+    position: absolute;
+    bottom: 0;
+    width: 999999999%;
+  }
+`;
+
+export default ErrorList;

+ 209 - 0
static/app/views/replays/detail/errorList/useErrorFilters.spec.tsx

@@ -0,0 +1,209 @@
+import {browserHistory} from 'react-router';
+import type {Location} from 'history';
+
+import {reactHooks} from 'sentry-test/reactTestingLibrary';
+
+import type {BreadcrumbTypeDefault, Crumb} from 'sentry/types/breadcrumbs';
+import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
+import type {Color} from 'sentry/utils/theme';
+import {useLocation} from 'sentry/utils/useLocation';
+
+import useNetworkFilters, {ErrorSelectOption, FilterFields} from './useErrorFilters';
+
+jest.mock('react-router');
+jest.mock('sentry/utils/useLocation');
+
+const mockUseLocation = useLocation as jest.MockedFunction<typeof useLocation>;
+const mockBrowserHistoryPush = browserHistory.push as jest.MockedFunction<
+  typeof browserHistory.push
+>;
+
+type DefaultCrumb = Extract<Crumb, BreadcrumbTypeDefault>;
+
+const ERROR_1_JS_RANGEERROR = {
+  type: BreadcrumbType.ERROR as const,
+  level: BreadcrumbLevelType.ERROR,
+  category: 'issue',
+  message: 'Invalid time value',
+  data: {
+    label: 'RangeError',
+    eventId: '415ecb5c85ac43b19f1886bb41ddab96',
+    groupId: 11,
+    groupShortId: 'JAVASCRIPT-RANGE',
+    project: 'javascript',
+  },
+  timestamp: '2023-06-09T12:00:00+00:00',
+  id: 360,
+  color: 'red300' as Color,
+  description: 'Error',
+};
+
+const ERROR_2_NEXTJS_TYPEERROR = {
+  type: BreadcrumbType.ERROR as const,
+  level: BreadcrumbLevelType.ERROR,
+  category: 'issue',
+  message: `undefined is not an object (evaluating 'e.apply').`,
+  data: {
+    label: 'TypeError',
+    eventId: 'ac43b19f1886bb41ddab96415ecb5c85',
+    groupId: 22,
+    groupShortId: 'NEXTJS-TYPE',
+    project: 'next-js',
+  },
+  timestamp: '2023-06-09T12:10:00+00:00',
+  id: 360,
+  color: 'red300' as Color,
+  description: 'Error',
+};
+
+const ERROR_3_JS_UNDEFINED = {
+  type: BreadcrumbType.ERROR as const,
+  level: BreadcrumbLevelType.ERROR,
+  category: 'issue',
+  message: 'Maximum update depth exceeded.',
+  data: {
+    label: 'Error',
+    eventId: '9f1886bb41ddab96415ecb5c85ac43b1',
+    groupId: 22,
+    groupShortId: 'JAVASCRIPT-UNDEF',
+    project: 'javascript',
+  },
+  timestamp: '2023-06-09T12:20:00+00:00',
+  id: 360,
+  color: 'red300' as Color,
+  description: 'Error',
+};
+
+describe('useErrorFilters', () => {
+  const errorCrumbs: DefaultCrumb[] = [
+    ERROR_1_JS_RANGEERROR,
+    ERROR_2_NEXTJS_TYPEERROR,
+    ERROR_3_JS_UNDEFINED,
+  ];
+
+  beforeEach(() => {
+    mockBrowserHistoryPush.mockReset();
+  });
+
+  it('should update the url when setters are called', () => {
+    const PROJECT_OPTION = {
+      value: 'resource.fetch',
+      label: 'resource.fetch',
+      qs: 'f_e_project',
+    } as ErrorSelectOption;
+    const SEARCH_FILTER = 'BadRequestError';
+
+    mockUseLocation
+      .mockReturnValueOnce({
+        pathname: '/',
+        query: {},
+      } as Location<FilterFields>)
+      .mockReturnValueOnce({
+        pathname: '/',
+        query: {f_e_project: [PROJECT_OPTION.value]},
+      } as Location<FilterFields>);
+
+    const {result, rerender} = reactHooks.renderHook(useNetworkFilters, {
+      initialProps: {errorCrumbs},
+    });
+
+    result.current.setFilters([PROJECT_OPTION]);
+    expect(browserHistory.push).toHaveBeenLastCalledWith({
+      pathname: '/',
+      query: {
+        f_e_project: [PROJECT_OPTION.value],
+      },
+    });
+
+    rerender();
+
+    result.current.setSearchTerm(SEARCH_FILTER);
+    expect(browserHistory.push).toHaveBeenLastCalledWith({
+      pathname: '/',
+      query: {
+        f_e_project: [PROJECT_OPTION.value],
+        f_e_search: SEARCH_FILTER,
+      },
+    });
+  });
+
+  it('should not filter anything when no values are set', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {},
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(useNetworkFilters, {
+      initialProps: {errorCrumbs},
+    });
+    expect(result.current.items).toHaveLength(3);
+  });
+
+  it('should filter by project', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_e_project: ['javascript'],
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(useNetworkFilters, {
+      initialProps: {errorCrumbs},
+    });
+    expect(result.current.items).toStrictEqual([
+      ERROR_1_JS_RANGEERROR,
+      ERROR_3_JS_UNDEFINED,
+    ]);
+  });
+
+  it('should filter by searchTerm', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_e_search: 'Maximum update depth',
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(useNetworkFilters, {
+      initialProps: {errorCrumbs},
+    });
+    expect(result.current.items).toHaveLength(1);
+  });
+});
+
+describe('getProjectOptions', () => {
+  it('should default to having nothing in the list of method types', () => {
+    const errorCrumbs = [];
+
+    const {result} = reactHooks.renderHook(useNetworkFilters, {
+      initialProps: {errorCrumbs},
+    });
+
+    expect(result.current.getProjectOptions()).toStrictEqual([]);
+  });
+
+  it('should return a sorted list of project slugs', () => {
+    const errorCrumbs = [ERROR_2_NEXTJS_TYPEERROR, ERROR_3_JS_UNDEFINED];
+
+    const {result} = reactHooks.renderHook(useNetworkFilters, {
+      initialProps: {errorCrumbs},
+    });
+
+    expect(result.current.getProjectOptions()).toStrictEqual([
+      {label: 'javascript', value: 'javascript', qs: 'f_e_project'},
+      {label: 'next-js', value: 'next-js', qs: 'f_e_project'},
+    ]);
+  });
+
+  it('should deduplicate BreadcrumbType', () => {
+    const errorCrumbs = [ERROR_1_JS_RANGEERROR, ERROR_3_JS_UNDEFINED];
+
+    const {result} = reactHooks.renderHook(useNetworkFilters, {
+      initialProps: {errorCrumbs},
+    });
+
+    expect(result.current.getProjectOptions()).toStrictEqual([
+      {label: 'javascript', value: 'javascript', qs: 'f_e_project'},
+    ]);
+  });
+});

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