Browse Source

ref(replays): Refactor tab filters to use the url bar to store data (#39030)

Refactor the Console, DOM & Network tabs so that their filter inputs put
the state inside the url instead of inside a `useState` hook.

This means users will be able to share their view of the page with each
other, and also we'll be able to deep-link into some views.


Related to #38861
Ryan Albrecht 2 years ago
parent
commit
b8b5e36fbd

+ 23 - 0
static/app/utils/replays/hooks/useFiltersInLocationQuery.tsx

@@ -0,0 +1,23 @@
+import {useCallback} from 'react';
+import {browserHistory} from 'react-router';
+import type {Query} from 'history';
+
+import {useLocation} from 'sentry/utils/useLocation';
+
+function useFiltersInLocationQuery<Q extends Query>() {
+  const {pathname, query} = useLocation<Q>();
+
+  const setFilter = useCallback(
+    (updatedQuery: Partial<Q>) => {
+      browserHistory.push({pathname, query: {...query, ...updatedQuery}});
+    },
+    [pathname, query]
+  );
+
+  return {
+    setFilter,
+    query,
+  };
+}
+
+export default useFiltersInLocationQuery;

+ 22 - 41
static/app/views/replays/detail/console/index.tsx

@@ -1,6 +1,5 @@
-import {useMemo, useRef, useState} from 'react';
+import {useRef} from 'react';
 import styled from '@emotion/styled';
-import debounce from 'lodash/debounce';
 
 import EmptyMessage from 'sentry/components/emptyMessage';
 import CompactSelect from 'sentry/components/forms/compactSelect';
@@ -10,16 +9,13 @@ import {relativeTimeInMs} from 'sentry/components/replays/utils';
 import SearchBar from 'sentry/components/searchBar';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import type {
-  BreadcrumbLevelType,
-  BreadcrumbTypeDefault,
-  Crumb,
-} from 'sentry/types/breadcrumbs';
+import type {BreadcrumbTypeDefault, Crumb} from 'sentry/types/breadcrumbs';
 import {defined} from 'sentry/utils';
 import {getPrevBreadcrumb} from 'sentry/utils/replays/getBreadcrumb';
 import {useCurrentItemScroller} from 'sentry/utils/replays/hooks/useCurrentItemScroller';
 import ConsoleMessage from 'sentry/views/replays/detail/console/consoleMessage';
-import {filterBreadcrumbs} from 'sentry/views/replays/detail/console/utils';
+import useConsoleFilters from 'sentry/views/replays/detail/console/useConsoleFilters';
+import {getLogLevels} from 'sentry/views/replays/detail/console/utils';
 import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
 
 interface Props {
@@ -27,24 +23,14 @@ interface Props {
   startTimestampMs: number;
 }
 
-const getDistinctLogLevels = (breadcrumbs: Crumb[]) =>
-  Array.from(
-    new Set<BreadcrumbLevelType>(breadcrumbs.map(breadcrumb => breadcrumb.level))
-  );
-
 function Console({breadcrumbs, startTimestampMs = 0}: Props) {
   const {currentHoverTime, currentTime} = useReplayContext();
-  const [searchTerm, setSearchTerm] = useState('');
-  const [logLevel, setLogLevel] = useState<BreadcrumbLevelType[]>([]);
-  const handleSearch = debounce(query => setSearchTerm(query), 150);
   const containerRef = useRef<HTMLDivElement>(null);
-
   useCurrentItemScroller(containerRef);
 
-  const filteredBreadcrumbs = useMemo(
-    () => filterBreadcrumbs(breadcrumbs, searchTerm, logLevel),
-    [logLevel, searchTerm, breadcrumbs]
-  );
+  const {items, logLevel, searchTerm, setLogLevel, setSearchTerm} = useConsoleFilters({
+    breadcrumbs,
+  });
 
   const currentUserAction = getPrevBreadcrumb({
     crumbs: breadcrumbs,
@@ -84,30 +70,25 @@ function Console({breadcrumbs, startTimestampMs = 0}: Props) {
     <ConsoleContainer>
       <ConsoleFilters>
         <CompactSelect
-          triggerProps={{
-            prefix: t('Log Level'),
-          }}
+          triggerProps={{prefix: t('Log Level')}}
           triggerLabel={logLevel.length === 0 ? t('Any') : null}
           multiple
-          options={getDistinctLogLevels(breadcrumbs).map(breadcrumbLogLevel => ({
-            value: breadcrumbLogLevel,
-            label: breadcrumbLogLevel,
-          }))}
-          onChange={selections =>
-            setLogLevel(selections.map(selection => selection.value))
-          }
+          options={getLogLevels(breadcrumbs).map(value => ({value, label: value}))}
+          onChange={selected => setLogLevel(selected.map(_ => _.value))}
           size="sm"
+          value={logLevel}
         />
         <SearchBar
-          onChange={handleSearch}
+          onChange={setSearchTerm}
           placeholder={t('Search console logs...')}
           size="sm"
+          query={searchTerm}
         />
       </ConsoleFilters>
       <ConsoleMessageContainer ref={containerRef}>
-        {filteredBreadcrumbs.length > 0 ? (
+        {items.length > 0 ? (
           <ConsoleTable>
-            {filteredBreadcrumbs.map((breadcrumb, i) => {
+            {items.map((breadcrumb, i) => {
               return (
                 <ConsoleMessage
                   isActive={closestUserAction?.id === breadcrumb.id}
@@ -137,13 +118,6 @@ const ConsoleContainer = styled(FluidHeight)`
   height: 100%;
 `;
 
-const ConsoleMessageContainer = styled(FluidHeight)`
-  overflow: auto;
-  border-radius: ${p => p.theme.borderRadius};
-  border: 1px solid ${p => p.theme.border};
-  box-shadow: ${p => p.theme.dropShadowLight};
-`;
-
 const ConsoleFilters = styled('div')`
   display: grid;
   gap: ${space(1)};
@@ -155,6 +129,13 @@ const ConsoleFilters = styled('div')`
   }
 `;
 
+const ConsoleMessageContainer = styled(FluidHeight)`
+  overflow: auto;
+  border-radius: ${p => p.theme.borderRadius};
+  border: 1px solid ${p => p.theme.border};
+  box-shadow: ${p => p.theme.dropShadowLight};
+`;
+
 const StyledEmptyMessage = styled(EmptyMessage)`
   align-items: center;
 `;

File diff suppressed because it is too large
+ 34 - 8
static/app/views/replays/detail/console/useConsoleFilters.spec.tsx


+ 76 - 0
static/app/views/replays/detail/console/useConsoleFilters.tsx

@@ -0,0 +1,76 @@
+import {useCallback, useMemo} from 'react';
+
+import type {BreadcrumbTypeDefault, Crumb} from 'sentry/types/breadcrumbs';
+import {isBreadcrumbTypeDefault} from 'sentry/types/breadcrumbs';
+import {decodeList, decodeScalar} from 'sentry/utils/queryString';
+import useFiltersInLocationQuery from 'sentry/utils/replays/hooks/useFiltersInLocationQuery';
+import {filterItems} from 'sentry/views/replays/detail/utils';
+
+export type FilterFields = {
+  f_c_logLevel: string[];
+  f_c_search: string;
+};
+
+type Item = Extract<Crumb, BreadcrumbTypeDefault>;
+
+type Options = {
+  breadcrumbs: Crumb[];
+};
+
+type Return = {
+  items: Item[];
+  logLevel: string[];
+  searchTerm: string;
+  setLogLevel: (logLevel: string[]) => void;
+  setSearchTerm: (searchTerm: string) => void;
+};
+
+const FILTERS = {
+  logLevel: (item: Item, logLevel: string[]) =>
+    logLevel.length === 0 || logLevel.includes(item.level),
+
+  searchTerm: (item: Item, searchTerm: string) =>
+    JSON.stringify(item.data).toLowerCase().includes(searchTerm),
+};
+
+function useConsoleFilters({breadcrumbs}: Options): Return {
+  const {setFilter, query} = useFiltersInLocationQuery<FilterFields>();
+
+  const typeDefaultCrumbs = useMemo(
+    () => breadcrumbs.filter(isBreadcrumbTypeDefault),
+    [breadcrumbs]
+  );
+
+  const logLevel = decodeList(query.f_c_logLevel);
+  const searchTerm = decodeScalar(query.f_c_search, '').toLowerCase();
+
+  const items = useMemo(
+    () =>
+      filterItems({
+        items: typeDefaultCrumbs,
+        filterFns: FILTERS,
+        filterVals: {logLevel, searchTerm},
+      }),
+    [typeDefaultCrumbs, logLevel, searchTerm]
+  );
+
+  const setLogLevel = useCallback(
+    (f_c_logLevel: string[]) => setFilter({f_c_logLevel}),
+    [setFilter]
+  );
+
+  const setSearchTerm = useCallback(
+    (f_c_search: string) => setFilter({f_c_search: f_c_search || undefined}),
+    [setFilter]
+  );
+
+  return {
+    items,
+    logLevel,
+    searchTerm,
+    setLogLevel,
+    setSearchTerm,
+  };
+}
+
+export default useConsoleFilters;

+ 5 - 20
static/app/views/replays/detail/console/utils.tsx

@@ -1,21 +1,6 @@
-import {BreadcrumbTypeDefault, Crumb} from 'sentry/types/breadcrumbs';
+import type {BreadcrumbLevelType, Crumb} from 'sentry/types/breadcrumbs';
 
-export const filterBreadcrumbs = (
-  breadcrumbs: Extract<Crumb, BreadcrumbTypeDefault>[],
-  searchTerm: string,
-  logLevel: Array<string>
-) => {
-  if (!searchTerm && logLevel.length === 0) {
-    return breadcrumbs;
-  }
-  return breadcrumbs.filter(breadcrumb => {
-    const normalizedSearchTerm = searchTerm.toLowerCase();
-    const doesMatch = JSON.stringify(breadcrumb.data)
-      .toLowerCase()
-      .includes(normalizedSearchTerm);
-    if (logLevel.length > 0) {
-      return doesMatch && logLevel.includes(breadcrumb.level);
-    }
-    return doesMatch;
-  });
-};
+export const getLogLevels = (breadcrumbs: Crumb[]) =>
+  Array.from(
+    new Set<BreadcrumbLevelType>(breadcrumbs.map(breadcrumb => breadcrumb.level))
+  );

+ 25 - 64
static/app/views/replays/detail/domMutations/index.tsx

@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useMemo, useState} from 'react';
+import {useEffect} from 'react';
 import {
   AutoSizer,
   CellMeasurer,
@@ -7,8 +7,6 @@ import {
   ListRowProps,
 } from 'react-virtualized';
 import styled from '@emotion/styled';
-import debounce from 'lodash/debounce';
-import isEmpty from 'lodash/isEmpty';
 
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import BreadcrumbIcon from 'sentry/components/events/interfaces/breadcrumbs/breadcrumb/type/icon';
@@ -22,13 +20,11 @@ import {SVGIconProps} from 'sentry/icons/svgIcon';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
-import useExtractedCrumbHtml, {
-  Extraction,
-} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
+import useExtractedCrumbHtml from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
 import type ReplayReader from 'sentry/utils/replays/replayReader';
+import useDomFilters from 'sentry/views/replays/detail/domMutations/useDomFilters';
 import {getDomMutationsTypes} from 'sentry/views/replays/detail/domMutations/utils';
 import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
-import {Filters, getFilteredItems} from 'sentry/views/replays/detail/utils';
 
 type Props = {
   replay: ReplayReader;
@@ -42,60 +38,31 @@ const cache = new CellMeasurerCache({
 
 function DomMutations({replay}: Props) {
   const {isLoading, actions} = useExtractedCrumbHtml({replay});
-  const [searchTerm, setSearchTerm] = useState('');
   let listRef: ReactVirtualizedList | null = null;
-  const [filters, setFilters] = useState<Filters<Extraction>>({});
 
-  const filteredDomMutations = useMemo(
-    () =>
-      getFilteredItems({
-        items: actions,
-        filters,
-        searchTerm,
-        searchProp: 'html',
-      }),
-    [actions, filters, searchTerm]
-  );
-
-  const handleSearch = useMemo(() => debounce(query => setSearchTerm(query), 150), []);
+  const {
+    items,
+    type: filteredTypes,
+    searchTerm,
+    setType,
+    setSearchTerm,
+  } = useDomFilters({actions});
 
   const startTimestampMs = replay.getReplay().startedAt.getTime();
 
   const {handleMouseEnter, handleMouseLeave, handleClick} =
     useCrumbHandlers(startTimestampMs);
 
-  const handleFilters = useCallback(
-    (
-      selectedValues: (string | number)[],
-      key: string,
-      filter: (mutation: Extraction) => boolean
-    ) => {
-      const filtersCopy = {...filters};
-
-      if (selectedValues.length === 0) {
-        delete filtersCopy[key];
-        setFilters(filtersCopy);
-        return;
-      }
-
-      setFilters({
-        ...filters,
-        [key]: filter,
-      });
-    },
-    [filters]
-  );
-
-  // Restart cache when filteredDomMutations changes
   useEffect(() => {
+    // Restart cache when items changes
     if (listRef) {
       cache.clearAll();
       listRef?.forceUpdateGrid();
     }
-  }, [filteredDomMutations, listRef]);
+  }, [items, listRef]);
 
   const renderRow = ({index, key, style, parent}: ListRowProps) => {
-    const mutation = filteredDomMutations[index];
+    const mutation = items[index];
     const {html, crumb} = mutation;
     const {title} = getDetails(crumb);
 
@@ -143,26 +110,20 @@ function DomMutations({replay}: Props) {
     <MutationContainer>
       <MutationFilters>
         <CompactSelect
-          triggerProps={{
-            prefix: t('Event Type'),
-          }}
-          triggerLabel={isEmpty(filters) ? t('Any') : null}
+          triggerProps={{prefix: t('Event Type')}}
+          triggerLabel={filteredTypes.length === 0 ? t('Any') : null}
           multiple
-          options={getDomMutationsTypes(actions).map(mutationEventType => ({
-            value: mutationEventType,
-            label: mutationEventType,
-          }))}
+          options={getDomMutationsTypes(actions).map(value => ({value, label: value}))}
           size="sm"
-          onChange={selections => {
-            const selectedValues = selections.map(selection => selection.value);
-
-            handleFilters(selectedValues, 'eventType', (mutation: Extraction) => {
-              return selectedValues.includes(mutation.crumb.type);
-            });
-          }}
+          onChange={selected => setType(selected.map(_ => _.value))}
+          value={filteredTypes}
+        />
+        <SearchBar
+          size="sm"
+          onChange={setSearchTerm}
+          placeholder={t('Search DOM')}
+          query={searchTerm}
         />
-
-        <SearchBar size="sm" onChange={handleSearch} placeholder={t('Search DOM')} />
       </MutationFilters>
       {isLoading ? (
         <Placeholder height="200px" />
@@ -177,7 +138,7 @@ function DomMutations({replay}: Props) {
                 deferredMeasurementCache={cache}
                 height={height}
                 overscanRowCount={5}
-                rowCount={filteredDomMutations.length}
+                rowCount={items.length}
                 noRowsRenderer={() => (
                   <EmptyStateWarning withIcon={false} small>
                     {t('No related DOM Events recorded')}

+ 164 - 0
static/app/views/replays/detail/domMutations/useDomFilters.spec.tsx

@@ -0,0 +1,164 @@
+import {browserHistory} from 'react-router';
+import type {Location} from 'history';
+
+import {reactHooks} from 'sentry-test/reactTestingLibrary';
+
+import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
+import type {Extraction} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
+import {useLocation} from 'sentry/utils/useLocation';
+
+import useDomFilters, {FilterFields} from './useDomFilters';
+
+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
+>;
+
+const actions: Extraction[] = [
+  {
+    crumb: {
+      type: BreadcrumbType.DEBUG,
+      timestamp: '2022-09-20T16:32:39.961Z',
+      level: BreadcrumbLevelType.INFO,
+      category: 'default',
+      data: {
+        action: 'largest-contentful-paint',
+        duration: 0,
+        size: 17782,
+        nodeId: 1126,
+        label: 'LCP',
+      },
+      id: 21,
+      color: 'purple300',
+      description: 'Debug',
+    },
+    html: '<div class="css-vruter e1weinmj3">HTTP 400 (invalid_grant): The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.</div>',
+    timestamp: 1663691559961,
+  },
+  {
+    crumb: {
+      type: BreadcrumbType.UI,
+      timestamp: '2022-09-20T16:32:50.812Z',
+      category: 'ui.click',
+      message: 'li.active > a.css-c5vwnq.e1ycxor00 > span.css-507rzt.e1lk5gpt0',
+      data: {
+        nodeId: 424,
+      },
+      id: 4,
+      color: 'purple300',
+      description: 'User Action',
+      level: BreadcrumbLevelType.UNDEFINED,
+    },
+    html: '<span aria-describedby="tooltip-nxf8deymg3" class="css-507rzt e1lk5gpt0">Ignored <span type="default" class="css-2uol17 e1gotaso0"><span><!-- 1 descendents --></span></span></span>',
+    timestamp: 1663691570812,
+  },
+  {
+    crumb: {
+      type: BreadcrumbType.UI,
+      timestamp: '2022-09-20T16:33:54.529Z',
+      category: 'ui.click',
+      message: 'div > div.exception > pre.exc-message.css-r7tqg9.e1rtpi7z1',
+      data: {
+        nodeId: 9304,
+      },
+      id: 17,
+      color: 'purple300',
+      description: 'User Action',
+      level: BreadcrumbLevelType.UNDEFINED,
+    },
+    html: '<div class="loadmore" style="display: block;">Load more..</div>',
+    timestamp: 1663691634529,
+  },
+];
+
+describe('useDomFilters', () => {
+  beforeEach(() => {
+    mockBrowserHistoryPush.mockReset();
+  });
+
+  it('should update the url when setters are called', () => {
+    const TYPE_FILTER = ['ui'];
+    const SEARCH_FILTER = 'aria';
+
+    mockUseLocation
+      .mockReturnValueOnce({
+        pathname: '/',
+        query: {},
+      } as Location<FilterFields>)
+      .mockReturnValueOnce({
+        pathname: '/',
+        query: {f_d_type: TYPE_FILTER},
+      } as Location<FilterFields>);
+
+    const {result, rerender} = reactHooks.renderHook(() => useDomFilters({actions}));
+
+    result.current.setType(TYPE_FILTER);
+    expect(browserHistory.push).toHaveBeenLastCalledWith({
+      pathname: '/',
+      query: {
+        f_d_type: TYPE_FILTER,
+      },
+    });
+
+    rerender();
+
+    result.current.setSearchTerm(SEARCH_FILTER);
+    expect(browserHistory.push).toHaveBeenLastCalledWith({
+      pathname: '/',
+      query: {
+        f_d_type: TYPE_FILTER,
+        f_d_search: SEARCH_FILTER,
+      },
+    });
+  });
+
+  it('should not filter anything when no values are set', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {},
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({actions}));
+    expect(result.current.items.length).toEqual(3);
+  });
+
+  it('should filter by logLevel', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_d_type: ['ui'],
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({actions}));
+    expect(result.current.items.length).toEqual(2);
+  });
+
+  it('should filter by searchTerm', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_d_search: 'aria',
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({actions}));
+    expect(result.current.items.length).toEqual(1);
+  });
+
+  it('should filter by searchTerm and logLevel', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_d_search: 'aria',
+        f_d_type: ['ui'],
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({actions}));
+    expect(result.current.items.length).toEqual(1);
+  });
+});

+ 65 - 0
static/app/views/replays/detail/domMutations/useDomFilters.tsx

@@ -0,0 +1,65 @@
+import {useCallback, useMemo} from 'react';
+
+import {decodeList, decodeScalar} from 'sentry/utils/queryString';
+import type {Extraction} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
+import useFiltersInLocationQuery from 'sentry/utils/replays/hooks/useFiltersInLocationQuery';
+import {filterItems} from 'sentry/views/replays/detail/utils';
+
+export type FilterFields = {
+  f_d_search: string;
+  f_d_type: string[];
+};
+
+type Options = {
+  actions: Extraction[];
+};
+
+type Return = {
+  items: Extraction[];
+  searchTerm: string;
+  setSearchTerm: (searchTerm: string) => void;
+  setType: (type: string[]) => void;
+  type: string[];
+};
+
+const FILTERS = {
+  type: (item: Extraction, type: string[]) =>
+    type.length === 0 || type.includes(item.crumb.type),
+
+  searchTerm: (item: Extraction, searchTerm: string) =>
+    JSON.stringify(item.html).toLowerCase().includes(searchTerm),
+};
+
+function useDomFilters({actions}: Options): Return {
+  const {setFilter, query} = useFiltersInLocationQuery<FilterFields>();
+
+  const type = decodeList(query.f_d_type);
+  const searchTerm = decodeScalar(query.f_d_search, '').toLowerCase();
+
+  const items = useMemo(
+    () =>
+      filterItems({
+        items: actions,
+        filterFns: FILTERS,
+        filterVals: {type, searchTerm},
+      }),
+    [actions, type, searchTerm]
+  );
+
+  const setType = useCallback((f_d_type: string[]) => setFilter({f_d_type}), [setFilter]);
+
+  const setSearchTerm = useCallback(
+    (f_d_search: string) => setFilter({f_d_search: f_d_search || undefined}),
+    [setFilter]
+  );
+
+  return {
+    items,
+    searchTerm,
+    setSearchTerm,
+    setType,
+    type,
+  };
+}
+
+export default useDomFilters;

+ 24 - 96
static/app/views/replays/detail/network/index.tsx

@@ -1,6 +1,5 @@
 import {Fragment, useCallback, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
-import debounce from 'lodash/debounce';
 
 import FileSize from 'sentry/components/fileSize';
 import CompactSelect from 'sentry/components/forms/compactSelect';
@@ -15,15 +14,14 @@ import space from 'sentry/styles/space';
 import {defined} from 'sentry/utils';
 import {ColorOrAlias} from 'sentry/utils/theme';
 import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
+import useNetworkFilters from 'sentry/views/replays/detail/network/useNetworkFilters';
 import {
   getResourceTypes,
   getStatusTypes,
   ISortConfig,
   NetworkSpan,
   sortNetwork,
-  UNKNOWN_STATUS,
 } from 'sentry/views/replays/detail/network/utils';
-import {Filters, getFilteredItems} from 'sentry/views/replays/detail/utils';
 import type {ReplayRecord} from 'sentry/views/replays/types';
 
 type Props = {
@@ -31,11 +29,6 @@ type Props = {
   replayRecord: ReplayRecord;
 };
 
-enum FilterTypesEnum {
-  RESOURCE_TYPE = 'resourceType',
-  STATUS = 'status',
-}
-
 function NetworkList({replayRecord, networkSpans}: Props) {
   const startTimestampMs = replayRecord.startedAt.getTime();
   const {setCurrentHoverTime, setCurrentTime} = useReplayContext();
@@ -44,21 +37,18 @@ function NetworkList({replayRecord, networkSpans}: Props) {
     asc: true,
     getValue: row => row[sortConfig.by],
   });
-  const [searchTerm, setSearchTerm] = useState('');
-  const [filters, setFilters] = useState<Filters<NetworkSpan>>({});
-
-  const filteredNetworkSpans = useMemo(
-    () =>
-      getFilteredItems({
-        items: networkSpans,
-        filters,
-        searchTerm,
-        searchProp: 'description',
-      }),
-    [filters, networkSpans, searchTerm]
-  );
 
-  const handleSearch = useMemo(() => debounce(query => setSearchTerm(query), 150), []);
+  const {
+    items,
+    status: selectedStatus,
+    type: selectedType,
+    searchTerm,
+    setStatus,
+    setType,
+    setSearchTerm,
+  } = useNetworkFilters({networkSpans});
+
+  const networkData = useMemo(() => sortNetwork(items, sortConfig), [items, sortConfig]);
 
   const handleMouseEnter = useCallback(
     (timestamp: number) => {
@@ -90,7 +80,6 @@ function NetworkList({replayRecord, networkSpans}: Props) {
 
   function handleSort(fieldName: keyof NetworkSpan): void;
   function handleSort(key: string, getValue: (row: NetworkSpan) => any): void;
-
   function handleSort(
     fieldName: string | keyof NetworkSpan,
     getValue?: (row: NetworkSpan) => any
@@ -106,11 +95,6 @@ function NetworkList({replayRecord, networkSpans}: Props) {
     });
   }
 
-  const networkData = useMemo(
-    () => sortNetwork(filteredNetworkSpans, sortConfig),
-    [filteredNetworkSpans, sortConfig]
-  );
-
   const sortArrow = (sortedBy: string) => {
     return sortConfig.by === sortedBy ? (
       <IconArrow
@@ -121,28 +105,6 @@ function NetworkList({replayRecord, networkSpans}: Props) {
     ) : null;
   };
 
-  const handleFilters = useCallback(
-    (
-      selectedValues: (string | number)[],
-      key: string,
-      filter: (network: NetworkSpan) => boolean
-    ) => {
-      const filtersCopy = {...filters};
-
-      if (selectedValues.length === 0) {
-        delete filtersCopy[key];
-        setFilters(filtersCopy);
-        return;
-      }
-
-      setFilters({
-        ...filters,
-        [key]: filter,
-      });
-    },
-    [filters]
-  );
-
   const columns = [
     <SortItem key="status">
       <UnstyledHeaderButton
@@ -238,62 +200,28 @@ function NetworkList({replayRecord, networkSpans}: Props) {
     <NetworkContainer>
       <NetworkFilters>
         <CompactSelect
-          triggerProps={{
-            prefix: t('Status'),
-          }}
-          triggerLabel={!filters[FilterTypesEnum.STATUS] ? t('Any') : null}
+          triggerProps={{prefix: t('Status')}}
+          triggerLabel={selectedStatus.length === 0 ? t('Any') : null}
           multiple
-          options={getStatusTypes(networkSpans).map(networkSpanStatusType => ({
-            value: networkSpanStatusType,
-            label: networkSpanStatusType,
-          }))}
+          options={getStatusTypes(networkSpans).map(value => ({value, label: value}))}
           size="sm"
-          onChange={selections => {
-            const selectedValues = selections.map(selection => selection.value);
-
-            handleFilters(
-              selectedValues,
-              FilterTypesEnum.STATUS,
-              (networkSpan: NetworkSpan) => {
-                if (
-                  selectedValues.includes(UNKNOWN_STATUS) &&
-                  !defined(networkSpan.data.statusCode)
-                ) {
-                  return true;
-                }
-
-                return selectedValues.includes(networkSpan.data.statusCode);
-              }
-            );
-          }}
+          onChange={selected => setStatus(selected.map(_ => _.value))}
+          value={selectedStatus}
         />
         <CompactSelect
-          triggerProps={{
-            prefix: t('Type'),
-          }}
-          triggerLabel={!filters[FilterTypesEnum.RESOURCE_TYPE] ? t('Any') : null}
+          triggerProps={{prefix: t('Type')}}
+          triggerLabel={selectedType.length === 0 ? t('Any') : null}
           multiple
-          options={getResourceTypes(networkSpans).map(networkSpanResourceType => ({
-            value: networkSpanResourceType,
-            label: networkSpanResourceType,
-          }))}
+          options={getResourceTypes(networkSpans).map(value => ({value, label: value}))}
           size="sm"
-          onChange={selections => {
-            const selectedValues = selections.map(selection => selection.value);
-
-            handleFilters(
-              selectedValues,
-              FilterTypesEnum.RESOURCE_TYPE,
-              (networkSpan: NetworkSpan) => {
-                return selectedValues.includes(networkSpan.op.replace('resource.', ''));
-              }
-            );
-          }}
+          onChange={selected => setType(selected.map(_ => _.value))}
+          value={selectedType}
         />
         <SearchBar
           size="sm"
-          onChange={handleSearch}
+          onChange={setSearchTerm}
           placeholder={t('Search Network...')}
+          query={searchTerm}
         />
       </NetworkFilters>
       <StyledPanelTable

+ 222 - 0
static/app/views/replays/detail/network/useNetworkFilters.spec.tsx

@@ -0,0 +1,222 @@
+import {browserHistory} from 'react-router';
+import type {Location} from 'history';
+
+import {reactHooks} from 'sentry-test/reactTestingLibrary';
+
+import {useLocation} from 'sentry/utils/useLocation';
+import {NetworkSpan} from 'sentry/views/replays/detail/network/utils';
+
+import useDomFilters, {FilterFields} from './useNetworkFilters';
+
+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
+>;
+
+const networkSpans: NetworkSpan[] = [
+  {
+    op: 'navigation.navigate',
+    description: 'http://localhost:3000/',
+    startTimestamp: 1663131080.5554,
+    endTimestamp: 1663131080.6947,
+    data: {
+      size: 1334,
+    },
+  },
+  {
+    op: 'resource.link',
+    description: 'http://localhost:3000/static/css/main.1856e8e3.chunk.css',
+    startTimestamp: 1663131080.5767,
+    endTimestamp: 1663131080.5951,
+    data: {
+      size: 300,
+    },
+  },
+  {
+    op: 'resource.script',
+    description: 'http://localhost:3000/static/js/2.3b866bed.chunk.js',
+    startTimestamp: 1663131080.5770998,
+    endTimestamp: 1663131080.5979,
+    data: {
+      size: 300,
+    },
+  },
+  {
+    op: 'resource.fetch',
+    description: 'https://pokeapi.co/api/v2/pokemon',
+    startTimestamp: 1663131080.641,
+    endTimestamp: 1663131080.65,
+    data: {
+      method: 'GET',
+      statusCode: 200,
+    },
+  },
+  {
+    op: 'resource.img',
+    description: 'http://localhost:3000/static/media/logo.ddd5084d.png',
+    startTimestamp: 1663131080.6422,
+    endTimestamp: 1663131080.6441,
+    data: {
+      size: 300,
+    },
+  },
+  {
+    op: 'resource.css',
+    description:
+      'http://localhost:3000/static/media/glyphicons-halflings-regular.448c34a5.woff2',
+    startTimestamp: 1663131080.6447997,
+    endTimestamp: 1663131080.6548998,
+    data: {
+      size: 300,
+    },
+  },
+  {
+    op: 'navigation.push',
+    description: '/mypokemon',
+    startTimestamp: 1663131082.346,
+    endTimestamp: 1663131082.346,
+    data: {},
+  },
+  {
+    op: 'resource.fetch',
+    description: 'https://pokeapi.co/api/v2/pokemon/pikachu',
+    startTimestamp: 1663131092.471,
+    endTimestamp: 1663131092.48,
+    data: {
+      method: 'GET',
+      statusCode: 200,
+    },
+  },
+  {
+    op: 'resource.fetch',
+    description: 'https://pokeapi.co/api/v2/pokemon/mewtu',
+    startTimestamp: 1663131120.198,
+    endTimestamp: 1663131122.693,
+    data: {
+      method: 'GET',
+      statusCode: 404,
+    },
+  },
+];
+
+describe('useDomFilters', () => {
+  beforeEach(() => {
+    mockBrowserHistoryPush.mockReset();
+  });
+
+  it('should update the url when setters are called', () => {
+    const TYPE_FILTER = ['fetch'];
+    const STATUS_FILTER = ['200'];
+    const SEARCH_FILTER = 'pikachu';
+
+    mockUseLocation
+      .mockReturnValueOnce({
+        pathname: '/',
+        query: {},
+      } as Location<FilterFields>)
+      .mockReturnValueOnce({
+        pathname: '/',
+        query: {f_n_type: TYPE_FILTER},
+      } as Location<FilterFields>)
+      .mockReturnValueOnce({
+        pathname: '/',
+        query: {f_n_type: TYPE_FILTER, f_n_status: STATUS_FILTER},
+      } as Location<FilterFields>);
+
+    const {result, rerender} = reactHooks.renderHook(() => useDomFilters({networkSpans}));
+
+    result.current.setType(TYPE_FILTER);
+    expect(browserHistory.push).toHaveBeenLastCalledWith({
+      pathname: '/',
+      query: {
+        f_n_type: TYPE_FILTER,
+      },
+    });
+
+    rerender();
+
+    result.current.setStatus(STATUS_FILTER);
+    expect(browserHistory.push).toHaveBeenLastCalledWith({
+      pathname: '/',
+      query: {
+        f_n_type: TYPE_FILTER,
+        f_n_status: STATUS_FILTER,
+      },
+    });
+
+    rerender();
+
+    result.current.setSearchTerm(SEARCH_FILTER);
+    expect(browserHistory.push).toHaveBeenLastCalledWith({
+      pathname: '/',
+      query: {
+        f_n_type: TYPE_FILTER,
+        f_n_status: STATUS_FILTER,
+        f_n_search: SEARCH_FILTER,
+      },
+    });
+  });
+
+  it('should not filter anything when no values are set', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {},
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({networkSpans}));
+    expect(result.current.items.length).toEqual(9);
+  });
+
+  it('should filter by status', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_n_status: ['200'],
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({networkSpans}));
+    expect(result.current.items.length).toEqual(2);
+  });
+
+  it('should filter by type', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_n_type: ['fetch'],
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({networkSpans}));
+    expect(result.current.items.length).toEqual(3);
+  });
+
+  it('should filter by searchTerm', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_n_search: 'pikachu',
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({networkSpans}));
+    expect(result.current.items.length).toEqual(1);
+  });
+
+  it('should filter by type, searchTerm and logLevel', () => {
+    mockUseLocation.mockReturnValue({
+      pathname: '/',
+      query: {
+        f_n_status: ['200'],
+        f_n_type: ['fetch'],
+        f_n_search: 'pokemon/',
+      },
+    } as Location<FilterFields>);
+
+    const {result} = reactHooks.renderHook(() => useDomFilters({networkSpans}));
+    expect(result.current.items.length).toEqual(1);
+  });
+});

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