Browse Source

Feat: Add filters to Dom Events tab (#38224)

Added filters to the Dom Events tabs. Now the user can filter by event type or search for a word that could be in the html value. 

https://user-images.githubusercontent.com/39612839/187308139-2d402ffa-cd06-4614-bf6e-21c68d4d15c0.mp4

Closes #38010
Jesus Padron 2 years ago
parent
commit
6fdf4ead4b

+ 1 - 1
static/app/utils/replays/hooks/useExtractedCrumbHtml.tsx

@@ -18,7 +18,7 @@ enum EventType {
   Plugin = 6,
 }
 
-type Extraction = {
+export type Extraction = {
   crumb: Crumb;
   html: string;
   timestamp: number;

+ 119 - 34
static/app/views/replays/detail/domMutations/index.tsx

@@ -1,16 +1,25 @@
+import {useCallback, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
 
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import BreadcrumbIcon from 'sentry/components/events/interfaces/breadcrumbs/breadcrumb/type/icon';
+import CompactSelect from 'sentry/components/forms/compactSelect';
 import HTMLCode from 'sentry/components/htmlCode';
 import {getDetails} from 'sentry/components/replays/breadcrumbs/utils';
 import PlayerRelativeTime from 'sentry/components/replays/playerRelativeTime';
+import SearchBar from 'sentry/components/searchBar';
 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 from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
+import useExtractedCrumbHtml, {
+  Extraction,
+} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
 import type ReplayReader from 'sentry/utils/replays/replayReader';
+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;
@@ -18,11 +27,49 @@ type Props = {
 
 function DomMutations({replay}: Props) {
   const {isLoading, actions} = useExtractedCrumbHtml({replay});
+  const [searchTerm, setSearchTerm] = useState('');
+  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 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]
+  );
+
   if (isLoading) {
     return null;
   }
@@ -36,42 +83,80 @@ function DomMutations({replay}: Props) {
   }
 
   return (
-    <MutationList>
-      {actions.map((mutation, i) => (
-        <MutationListItem
-          key={i}
-          onMouseEnter={() => handleMouseEnter(mutation.crumb)}
-          onMouseLeave={() => handleMouseLeave(mutation.crumb)}
-        >
-          {i < actions.length - 1 && <StepConnector />}
-          <IconWrapper color={mutation.crumb.color}>
-            <BreadcrumbIcon type={mutation.crumb.type} />
-          </IconWrapper>
-          <MutationContent>
-            <MutationDetailsContainer>
-              <div>
-                <TitleContainer>
-                  <Title>{getDetails(mutation.crumb).title}</Title>
-                </TitleContainer>
-                <MutationMessage>{mutation.crumb.message}</MutationMessage>
-              </div>
-              <UnstyledButton onClick={() => handleClick(mutation.crumb)}>
-                <PlayerRelativeTime
-                  relativeTimeMs={startTimestampMs}
-                  timestamp={mutation.crumb.timestamp}
-                />
-              </UnstyledButton>
-            </MutationDetailsContainer>
-            <CodeContainer>
-              <HTMLCode code={mutation.html} />
-            </CodeContainer>
-          </MutationContent>
-        </MutationListItem>
-      ))}
-    </MutationList>
+    <MutationContainer>
+      <MutationFilters>
+        <CompactSelect
+          triggerProps={{
+            prefix: t('Event Type'),
+          }}
+          multiple
+          options={getDomMutationsTypes(actions).map(mutationEventType => ({
+            value: mutationEventType,
+            label: mutationEventType,
+          }))}
+          size="sm"
+          onChange={selections => {
+            const selectedValues = selections.map(selection => selection.value);
+
+            handleFilters(selectedValues, 'eventType', (mutation: Extraction) => {
+              return selectedValues.includes(mutation.crumb.type);
+            });
+          }}
+        />
+
+        <SearchBar size="sm" onChange={handleSearch} placeholder={t('Search DOM')} />
+      </MutationFilters>
+      <MutationList>
+        {filteredDomMutations.map((mutation, i) => (
+          <MutationListItem
+            key={i}
+            onMouseEnter={() => handleMouseEnter(mutation.crumb)}
+            onMouseLeave={() => handleMouseLeave(mutation.crumb)}
+          >
+            {i < actions.length - 1 && <StepConnector />}
+            <IconWrapper color={mutation.crumb.color}>
+              <BreadcrumbIcon type={mutation.crumb.type} />
+            </IconWrapper>
+            <MutationContent>
+              <MutationDetailsContainer>
+                <div>
+                  <TitleContainer>
+                    <Title>{getDetails(mutation.crumb).title}</Title>
+                  </TitleContainer>
+                  <MutationMessage>{mutation.crumb.message}</MutationMessage>
+                </div>
+                <UnstyledButton onClick={() => handleClick(mutation.crumb)}>
+                  <PlayerRelativeTime
+                    relativeTimeMs={startTimestampMs}
+                    timestamp={mutation.crumb.timestamp}
+                  />
+                </UnstyledButton>
+              </MutationDetailsContainer>
+              <CodeContainer>
+                <HTMLCode code={mutation.html} />
+              </CodeContainer>
+            </MutationContent>
+          </MutationListItem>
+        ))}
+      </MutationList>
+    </MutationContainer>
   );
 }
 
+const MutationContainer = styled(FluidHeight)`
+  height: 100%;
+`;
+
+const MutationFilters = styled('div')`
+  display: grid;
+  gap: ${space(1)};
+  grid-template-columns: max-content 1fr;
+  margin-bottom: ${space(1)};
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    margin-top: ${space(1)};
+  }
+`;
+
 const MutationList = styled('ul')`
   list-style: none;
   position: relative;

+ 7 - 0
static/app/views/replays/detail/domMutations/utils.tsx

@@ -0,0 +1,7 @@
+import {BreadcrumbType} from 'sentry/types/breadcrumbs';
+import {Extraction} from 'sentry/utils/replays/hooks/useExtractedCrumbHtml';
+
+export const getDomMutationsTypes = (actions: Extraction[]) =>
+  Array.from(
+    new Set<BreadcrumbType>(actions.map(mutation => mutation.crumb.type).sort())
+  );

+ 46 - 0
static/app/views/replays/detail/utils.tsx

@@ -0,0 +1,46 @@
+export type Filters<T> = {
+  [key: string]: (item: T) => boolean;
+};
+
+export const getFilteredItems = <T,>({
+  filters,
+  items,
+  searchTerm,
+  searchProp,
+}: {
+  filters: Filters<T>;
+  items: T[];
+  searchProp: keyof T;
+  searchTerm: string;
+}) => {
+  if (!searchTerm && Object.keys(filters).length === 0) {
+    return items;
+  }
+
+  const normalizedSearchTerm = searchTerm.toLowerCase();
+
+  return items.filter(item => {
+    let doesMatch = false;
+    const searchValue = item[searchProp];
+
+    if (typeof searchValue === 'string') {
+      doesMatch = searchValue.toLowerCase().includes(normalizedSearchTerm);
+    } else {
+      // As this is a generic typed value, we can't know for sure its value type. So we use JSON.stringify to make sure we get a string.
+      doesMatch = JSON.stringify(searchValue)
+        .toLowerCase()
+        .includes(normalizedSearchTerm);
+    }
+
+    for (const key in filters) {
+      if (filters.hasOwnProperty(key)) {
+        const filter = filters[key];
+        if (!filter(item)) {
+          return false;
+        }
+      }
+    }
+
+    return doesMatch;
+  });
+};