Browse Source

feat(replays): Replay layout breadcrumbs filter (#57942)

Added breadcrumb filters via a search bar and type selector

Closes https://github.com/getsentry/team-replay/issues/197
Catherine Lee 1 year ago
parent
commit
7dda2ac663

+ 43 - 0
static/app/views/replays/detail/breadcrumbs/breadcrumbFilters.tsx

@@ -0,0 +1,43 @@
+import {CompactSelect} from 'sentry/components/compactSelect';
+import SearchBar from 'sentry/components/searchBar';
+import {t} from 'sentry/locale';
+import useBreadcrumbFilters from 'sentry/views/replays/detail/breadcrumbs/useBreadcrumbFilters';
+import FiltersGrid from 'sentry/views/replays/detail/filtersGrid';
+
+type Props = {
+  frames: undefined | unknown[];
+} & ReturnType<typeof useBreadcrumbFilters>;
+
+function BreadcrumbFilters({
+  frames,
+  getBreadcrumbTypes,
+  searchTerm,
+  setSearchTerm,
+  setType,
+  type,
+}: Props) {
+  const breadcrumbTypes = getBreadcrumbTypes();
+  return (
+    <FiltersGrid>
+      <CompactSelect
+        triggerProps={{prefix: t('Type')}}
+        triggerLabel={type.length === 0 ? t('Any') : null}
+        multiple
+        options={breadcrumbTypes}
+        size="sm"
+        onChange={selected => setType(selected.map(({value}) => value))}
+        value={type}
+        disabled={!breadcrumbTypes.length}
+      />
+      <SearchBar
+        size="sm"
+        onChange={setSearchTerm}
+        placeholder={t('Search Breadcrumb Events')}
+        query={searchTerm}
+        disabled={!frames || !frames.length}
+      />
+    </FiltersGrid>
+  );
+}
+
+export default BreadcrumbFilters;

+ 15 - 5
static/app/views/replays/detail/breadcrumbs/index.tsx

@@ -12,7 +12,9 @@ import getFrameDetails from 'sentry/utils/replays/getFrameDetails';
 import useActiveReplayTab from 'sentry/utils/replays/hooks/useActiveReplayTab';
 import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers';
 import type {ReplayFrame} from 'sentry/utils/replays/types';
+import BreadcrumbFilters from 'sentry/views/replays/detail/breadcrumbs/breadcrumbFilters';
 import BreadcrumbRow from 'sentry/views/replays/detail/breadcrumbs/breadcrumbRow';
+import useBreadcrumbFilters from 'sentry/views/replays/detail/breadcrumbs/useBreadcrumbFilters';
 import useScrollToCurrentItem from 'sentry/views/replays/detail/breadcrumbs/useScrollToCurrentItem';
 import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
 import NoRowRenderer from 'sentry/views/replays/detail/noRowRenderer';
@@ -48,7 +50,11 @@ function Breadcrumbs({frames, startTimestampMs}: Props) {
   // re-render when items are expanded/collapsed, though it may work in state as well.
   const expandPathsRef = useRef(new Map<number, Set<string>>());
 
-  const deps = useMemo(() => [frames], [frames]);
+  const filterProps = useBreadcrumbFilters({frames: frames || []});
+  const {items, searchTerm, setSearchTerm} = filterProps;
+  const clearSearchTerm = () => setSearchTerm('');
+
+  const deps = useMemo(() => [items, searchTerm], [items, searchTerm]);
   const {cache, updateList} = useVirtualizedList({
     cellMeasurer,
     ref: listRef,
@@ -66,7 +72,7 @@ function Breadcrumbs({frames, startTimestampMs}: Props) {
   });
 
   const renderRow = ({index, key, style, parent}: ListRowProps) => {
-    const item = (frames || [])[index];
+    const item = (items || [])[index];
 
     return (
       <CellMeasurer
@@ -94,7 +100,8 @@ function Breadcrumbs({frames, startTimestampMs}: Props) {
 
   return (
     <FluidHeight>
-      <TabItemContainer>
+      <BreadcrumbFilters frames={frames} {...filterProps} />
+      <TabItemContainer data-test-id="replay-details-breadcrumbs-tab">
         {frames ? (
           <AutoSizer onResize={updateList}>
             {({height, width}) => (
@@ -102,13 +109,16 @@ function Breadcrumbs({frames, startTimestampMs}: Props) {
                 deferredMeasurementCache={cache}
                 height={height}
                 noRowsRenderer={() => (
-                  <NoRowRenderer unfilteredItems={frames} clearSearchTerm={() => {}}>
+                  <NoRowRenderer
+                    unfilteredItems={frames}
+                    clearSearchTerm={clearSearchTerm}
+                  >
                     {t('No breadcrumbs recorded')}
                   </NoRowRenderer>
                 )}
                 overscanRowCount={5}
                 ref={listRef}
-                rowCount={frames.length}
+                rowCount={items.length}
                 rowHeight={cache.rowHeight}
                 rowRenderer={renderRow}
                 width={width}

+ 137 - 0
static/app/views/replays/detail/breadcrumbs/useBreadcrumbFilters.tsx

@@ -0,0 +1,137 @@
+import {useCallback, useMemo} from 'react';
+import uniq from 'lodash/uniq';
+
+import {decodeList, decodeScalar} from 'sentry/utils/queryString';
+import useFiltersInLocationQuery from 'sentry/utils/replays/hooks/useFiltersInLocationQuery';
+import {getFrameOpOrCategory, ReplayFrame} from 'sentry/utils/replays/types';
+import {filterItems} from 'sentry/views/replays/detail/utils';
+
+export type FilterFields = {
+  f_b_search: string;
+  f_b_type: string[];
+};
+
+type Options = {
+  frames: ReplayFrame[];
+};
+
+type Return = {
+  getBreadcrumbTypes: () => {label: string; value: string}[];
+  items: ReplayFrame[];
+  searchTerm: string;
+  setSearchTerm: (searchTerm: string) => void;
+  setType: (type: string[]) => void;
+  type: string[];
+};
+
+const TYPE_TO_LABEL: Record<string, string> = {
+  start: 'Replay Start',
+  replay: 'Replay',
+  issue: 'Issue',
+  console: 'Console',
+  nav: 'Navigation',
+  pageLoad: 'Page Load',
+  reload: 'Reload',
+  navBackForward: 'Navigate Back/Forward',
+  memory: 'Memory',
+  paint: 'Paint',
+  blur: 'User Blur',
+  action: 'User Action',
+  rageOrMulti: 'Rage & Multi Click',
+  rageOrDead: 'Rage & Dead Click',
+  lcp: 'LCP',
+  click: 'User Click',
+  keydown: 'KeyDown',
+  input: 'Input',
+};
+
+const OPORCATEGORY_TO_TYPE: Record<string, keyof typeof TYPE_TO_LABEL> = {
+  'replay.init': 'start',
+  'replay.mutations': 'replay',
+  issue: 'issue',
+  console: 'console',
+  navigation: 'nav',
+  'navigation.push': 'nav',
+  'navigation.navigate': 'pageLoad',
+  'navigation.reload': 'reload',
+  'navigation.back_forward': 'navBackForward',
+  memory: 'memory',
+  paint: 'paint',
+  'ui.blur': 'blur',
+  'ui.focus': 'action',
+  'ui.multiClick': 'rageOrMulti',
+  'ui.slowClickDetected': 'rageOrDead',
+  'largest-contentful-paint': 'lcp',
+  'ui.click': 'click',
+  'ui.keyDown': 'keydown',
+  'ui.input': 'input',
+};
+
+function typeToLabel(val: string): string {
+  return TYPE_TO_LABEL[val] ?? 'Unknown';
+}
+
+const FILTERS = {
+  type: (item: ReplayFrame, type: string[]) =>
+    type.length === 0 || type.includes(getFrameOpOrCategory(item)),
+  searchTerm: (item: ReplayFrame, searchTerm: string) =>
+    JSON.stringify(item).toLowerCase().includes(searchTerm),
+};
+
+function useBreadcrumbFilters({frames}: Options): Return {
+  const {setFilter, query} = useFiltersInLocationQuery<FilterFields>();
+
+  const type = useMemo(() => decodeList(query.f_b_type), [query.f_b_type]);
+  const searchTerm = decodeScalar(query.f_b_search, '').toLowerCase();
+
+  const items = useMemo(() => {
+    // flips OPORCATERGORY_TO_TYPE and prevents overwriting nav entry, nav entry becomes nav: ['navigation','navigation.push']
+    const TYPE_TO_OPORCATEGORY = Object.entries(OPORCATEGORY_TO_TYPE).reduce(
+      (dict, [key, value]) =>
+        dict[value] ? {...dict, [value]: [dict[value], key]} : {...dict, [value]: key},
+      {}
+    );
+    const OpOrCategory = type.map(theType => TYPE_TO_OPORCATEGORY[theType]).flat();
+    return filterItems({
+      items: frames,
+      filterFns: FILTERS,
+      filterVals: {
+        type: OpOrCategory,
+        searchTerm,
+      },
+    });
+  }, [frames, type, searchTerm]);
+
+  const getBreadcrumbTypes = useCallback(
+    () =>
+      uniq(
+        frames
+          .map(frame => OPORCATEGORY_TO_TYPE[getFrameOpOrCategory(frame)])
+          .concat(type)
+      )
+        .sort()
+        .map(value => ({
+          value,
+          label: typeToLabel(value),
+        })),
+    [frames, type]
+  );
+
+  const setType = useCallback((f_b_type: string[]) => setFilter({f_b_type}), [setFilter]);
+
+  const setSearchTerm = useCallback(
+    (f_b_search: string) => setFilter({f_b_search: f_b_search || undefined}),
+    [setFilter]
+  );
+
+  return {
+    getBreadcrumbTypes,
+    items,
+    searchTerm,
+    setSearchTerm,
+    setType,
+    type,
+  };
+}
+
+export default useBreadcrumbFilters;