Browse Source

feat(replays): new widgets with example replays (#57099)

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

Both tables with 3 rows: 
<img width="1185" alt="SCR-20230927-rmuv"
src="https://github.com/getsentry/sentry/assets/56095982/74cd6ae4-963c-4c10-a68b-5ecb9a5857c7">

3 rows / < 3 rows:
<img width="1197" alt="SCR-20230927-rnzf"
src="https://github.com/getsentry/sentry/assets/56095982/b14d9228-cef8-43c5-817a-e2a10c0d2db9">

3 rows / no rows:
<img width="1194" alt="SCR-20230927-rnsd"
src="https://github.com/getsentry/sentry/assets/56095982/86c8422a-27ff-41e9-b7b1-f8240f3821a8">

Both no rows:
<img width="1190" alt="SCR-20230927-rnff"
src="https://github.com/getsentry/sentry/assets/56095982/a8018f9c-a6fb-4609-ae29-b6f329d18389">
Michelle Zhang 1 year ago
parent
commit
12ffe21cef

+ 231 - 56
static/app/views/replays/deadRageClick/deadRageSelectorCards.tsx

@@ -1,97 +1,272 @@
-import {Fragment} from 'react';
+import {ComponentProps, ReactNode, useState} from 'react';
 import styled from '@emotion/styled';
-import {Location} from 'history';
 
+import {LinkButton} from 'sentry/components/button';
+import EmptyStateWarning from 'sentry/components/emptyStateWarning';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import QuestionTooltip from 'sentry/components/questionTooltip';
+import TextOverflow from 'sentry/components/textOverflow';
 import {IconCursorArrow} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import useDeadRageSelectors from 'sentry/utils/replays/hooks/useDeadRageSelectors';
+import {ColorOrAlias} from 'sentry/utils/theme';
 import {useLocation} from 'sentry/utils/useLocation';
-import SelectorTable from 'sentry/views/replays/deadRageClick/selectorTable';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import Accordion from 'sentry/views/performance/landing/widgets/components/accordion';
+import {RightAlignedCell} from 'sentry/views/performance/landing/widgets/components/selectableList';
+import {
+  ContentContainer,
+  HeaderContainer,
+  HeaderTitleLegend,
+  StatusContainer,
+  Subtitle,
+  WidgetContainer,
+} from 'sentry/views/profiling/landing/styles';
+import ExampleReplaysList from 'sentry/views/replays/deadRageClick/exampleReplaysList';
 
 function DeadRageSelectorCards() {
-  const location = useLocation();
-
   return (
     <SplitCardContainer>
-      <DeadClickTable location={location} />
-      <RageClickTable location={location} />
+      <AccordionWidget
+        clickType="count_dead_clicks"
+        header={
+          <div>
+            <StyledWidgetHeader>
+              {t('Most Dead Clicks')}
+              <QuestionTooltip
+                size="xs"
+                position="top"
+                title={t('The top selectors your users have dead clicked on.')}
+                isHoverable
+              />
+            </StyledWidgetHeader>
+            <Subtitle>{t('Suggested replays to watch')}</Subtitle>
+          </div>
+        }
+        deadOrRage="dead"
+      />
+      <AccordionWidget
+        clickType="count_rage_clicks"
+        header={
+          <div>
+            <StyledWidgetHeader>
+              {t('Most Rage Clicks')}
+              <QuestionTooltip
+                size="xs"
+                position="top"
+                title={t('The top selectors your users have rage clicked on.')}
+                isHoverable
+              />
+            </StyledWidgetHeader>
+            <Subtitle>{t('Suggested replays to watch')}</Subtitle>
+          </div>
+        }
+        deadOrRage="rage"
+      />
     </SplitCardContainer>
   );
 }
 
-function DeadClickTable({location}: {location: Location<any>}) {
+function AccordionWidget({
+  clickType,
+  deadOrRage,
+  header,
+}: {
+  clickType: 'count_dead_clicks' | 'count_rage_clicks';
+  deadOrRage: 'dead' | 'rage';
+  header: ReactNode;
+}) {
+  const [selectedListIndex, setSelectListIndex] = useState(0);
   const {isLoading, isError, data} = useDeadRageSelectors({
-    per_page: 4,
-    sort: '-count_dead_clicks',
+    per_page: 3,
+    sort: `-${clickType}`,
     cursor: undefined,
     prefix: 'selector_',
     isWidgetData: true,
   });
+  const location = useLocation();
+  const filteredData = data.filter(d => (d[clickType] ?? 0) > 0);
+  const clickColor = deadOrRage === 'dead' ? ('yellow300' as ColorOrAlias) : 'red300';
 
   return (
-    <SelectorTable
-      data={data.filter(d => (d.count_dead_clicks ?? 0) > 0)}
-      isError={isError}
-      isLoading={isLoading}
-      location={location}
-      clickCountColumns={[{key: 'count_dead_clicks', name: 'dead clicks'}]}
-      title={
-        <Fragment>
-          <IconContainer>
-            <IconCursorArrow size="xs" color="yellow300" />
-          </IconContainer>
-          {t('Most Dead Clicks')}
-        </Fragment>
-      }
-      customHandleResize={() => {}}
-      clickCountSortable={false}
-    />
+    <StyledWidgetContainer>
+      <StyledHeaderContainer>
+        <ClickColor color={clickColor}>
+          <IconCursorArrow />
+        </ClickColor>
+        {header}
+      </StyledHeaderContainer>
+      {isLoading && (
+        <StatusContainer>
+          <LoadingIndicator />
+        </StatusContainer>
+      )}
+      {isError || (!isLoading && filteredData.length === 0) ? (
+        <CenteredContentContainer>
+          <EmptyStateWarning>
+            <p>{t('No results found')}</p>
+          </EmptyStateWarning>
+        </CenteredContentContainer>
+      ) : (
+        <LeftAlignedContentContainer>
+          <Accordion
+            expandedIndex={selectedListIndex}
+            setExpandedIndex={setSelectListIndex}
+            items={filteredData.map(d => {
+              return {
+                header: () => (
+                  <AccordionItemHeader
+                    count={d[clickType] ?? 0}
+                    selector={d.dom_element}
+                    clickColor={clickColor}
+                  />
+                ),
+                content: () => (
+                  <ExampleReplaysList
+                    location={location}
+                    clickType={clickType}
+                    query={`${deadOrRage}.selector:"${transformSelectorQuery(
+                      d.dom_element
+                    )}"`}
+                  />
+                ),
+              };
+            })}
+          />
+        </LeftAlignedContentContainer>
+      )}
+      <SearchButton
+        label={t('See all selectors')}
+        path="selectors"
+        sort={`-${clickType}`}
+      />
+    </StyledWidgetContainer>
   );
 }
 
-function RageClickTable({location}: {location: Location<any>}) {
-  const {isLoading, isError, data} = useDeadRageSelectors({
-    per_page: 4,
-    sort: '-count_rage_clicks',
-    cursor: undefined,
-    prefix: 'selector_',
-    isWidgetData: true,
-  });
+function transformSelectorQuery(selector: string) {
+  return selector
+    .replaceAll('"', `\\"`)
+    .replaceAll('aria=', 'aria-label=')
+    .replaceAll('testid=', 'data-test-id=');
+}
+
+function AccordionItemHeader({
+  count,
+  clickColor,
+  selector,
+}: {
+  clickColor: ColorOrAlias;
+  count: number;
+  selector: string;
+}) {
+  const clickCount = (
+    <ClickColor color={clickColor}>
+      <IconCursorArrow size="xs" />
+      {count}
+    </ClickColor>
+  );
+  return (
+    <StyledAccordionHeader>
+      <TextOverflow>
+        <code>{selector}</code>
+      </TextOverflow>
+      <RightAlignedCell>{clickCount}</RightAlignedCell>
+    </StyledAccordionHeader>
+  );
+}
+
+function SearchButton({
+  label,
+  sort,
+  path,
+  ...props
+}: {
+  label: ReactNode;
+  path: string;
+  sort: string;
+} & Omit<ComponentProps<typeof LinkButton>, 'size' | 'to'>) {
+  const location = useLocation();
+  const organization = useOrganization();
 
   return (
-    <SelectorTable
-      data={data.filter(d => (d.count_rage_clicks ?? 0) > 0)}
-      isError={isError}
-      isLoading={isLoading}
-      location={location}
-      clickCountColumns={[{key: 'count_rage_clicks', name: 'rage clicks'}]}
-      title={
-        <Fragment>
-          <IconContainer>
-            <IconCursorArrow size="xs" color="red300" />
-          </IconContainer>
-          {t('Most Rage Clicks')}
-        </Fragment>
-      }
-      customHandleResize={() => {}}
-      clickCountSortable={false}
-    />
+    <StyledButton
+      {...props}
+      size="xs"
+      to={{
+        pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${path}/`),
+        query: {
+          ...location.query,
+          sort,
+          query: undefined,
+          cursor: undefined,
+        },
+      }}
+    >
+      {label}
+    </StyledButton>
   );
 }
 
 const SplitCardContainer = styled('div')`
   display: grid;
   grid-template-columns: 1fr 1fr;
-  grid-template-rows: max-content max-content;
+  grid-template-rows: max-content;
   grid-auto-flow: column;
   gap: 0 ${space(2)};
   align-items: stretch;
-  padding-top: ${space(1)};
 `;
 
-const IconContainer = styled('span')`
-  margin-right: ${space(1)};
+const ClickColor = styled(TextOverflow)<{color: ColorOrAlias}>`
+  color: ${p => p.theme[p.color]};
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: ${space(0.5)};
+  align-items: center;
+`;
+
+const StyledHeaderContainer = styled(HeaderContainer)`
+  grid-auto-flow: row;
+  align-items: center;
+  grid-template-rows: auto;
+  grid-template-columns: 30px auto;
+`;
+
+const LeftAlignedContentContainer = styled(ContentContainer)`
+  justify-content: flex-start;
+`;
+
+const CenteredContentContainer = styled(ContentContainer)`
+  justify-content: center;
+`;
+
+const StyledButton = styled(LinkButton)`
+  width: 100%;
+  border-radius: ${p => p.theme.borderRadiusBottom};
+  padding: ${space(3)};
+  border-bottom: none;
+  border-left: none;
+  border-right: none;
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
+const StyledAccordionHeader = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr max-content;
+  flex: 1;
+`;
+
+const StyledWidgetHeader = styled(HeaderTitleLegend)`
+  display: grid;
+  gap: ${space(1)};
+  justify-content: start;
+  align-items: center;
+`;
+
+const StyledWidgetContainer = styled(WidgetContainer)`
+  margin-bottom: 0;
 `;
 
 export default DeadRageSelectorCards;

+ 103 - 0
static/app/views/replays/deadRageClick/exampleReplaysList.tsx

@@ -0,0 +1,103 @@
+import {Fragment, useMemo} from 'react';
+import {Location} from 'history';
+
+import EmptyStateWarning from 'sentry/components/emptyStateWarning';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import EventView from 'sentry/utils/discover/eventView';
+import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
+import useReplayList from 'sentry/utils/replays/hooks/useReplayList';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useRoutes} from 'sentry/utils/useRoutes';
+import {StatusContainer} from 'sentry/views/profiling/landing/styles';
+import {ReplayCell} from 'sentry/views/replays/replayTable/tableCell';
+
+export default function ExampleReplaysList({
+  location,
+  clickType,
+  query,
+}: {
+  clickType: 'count_dead_clicks' | 'count_rage_clicks';
+  location: Location;
+  query: string;
+}) {
+  const organization = useOrganization();
+  const {project, environment, start, statsPeriod, utc, end} = location.query;
+  const emptyLocation: Location = useMemo(() => {
+    return {
+      pathname: '',
+      search: '',
+      hash: '',
+      state: '',
+      action: 'PUSH' as const,
+      key: '',
+      query: {project, environment, start, statsPeriod, utc, end},
+    };
+  }, [project, environment, start, statsPeriod, utc, end]);
+
+  const eventView = useMemo(
+    () =>
+      EventView.fromNewQueryWithLocation(
+        {
+          id: '',
+          name: '',
+          version: 2,
+          fields: [
+            'activity',
+            'duration',
+            'id',
+            'project_id',
+            'user',
+            'finished_at',
+            'is_archived',
+            'started_at',
+            'urls',
+          ],
+          projects: [],
+          query,
+          orderby: `-${clickType}`,
+        },
+        emptyLocation
+      ),
+    [emptyLocation, query, clickType]
+  );
+
+  const {replays, isFetching, fetchError} = useReplayList({
+    eventView,
+    location: emptyLocation,
+    organization,
+    perPage: 3,
+  });
+
+  const routes = useRoutes();
+  const referrer = getRouteStringFromRoutes(routes);
+
+  return (
+    <Fragment>
+      {isFetching && (
+        <StatusContainer>
+          <LoadingIndicator />
+        </StatusContainer>
+      )}
+      {fetchError || (!isFetching && !replays?.length) ? (
+        <EmptyStateWarning withIcon={false} small>
+          {t('No replays found')}
+        </EmptyStateWarning>
+      ) : (
+        replays?.map(r => {
+          return (
+            <ReplayCell
+              key="session"
+              replay={r}
+              eventView={eventView}
+              organization={organization}
+              referrer={referrer}
+              showUrl={false}
+              referrer_table="selector-widget"
+            />
+          );
+        })
+      )}
+    </Fragment>
+  );
+}

+ 1 - 3
static/app/views/replays/deadRageClick/selectorTable.tsx

@@ -48,7 +48,6 @@ interface Props {
   isError: boolean;
   isLoading: boolean;
   location: Location<any>;
-  customHandleResize?: () => void;
   title?: ReactNode;
 }
 
@@ -65,7 +64,6 @@ export default function SelectorTable({
   isLoading,
   location,
   title,
-  customHandleResize,
   clickCountSortable,
 }: Props) {
   const organization = useOrganization();
@@ -121,7 +119,7 @@ export default function SelectorTable({
       columnSortBy={[]}
       stickyHeader
       grid={{
-        onResizeColumn: customHandleResize ?? handleResizeColumn,
+        onResizeColumn: handleResizeColumn,
         renderHeadCell,
         renderBodyCell,
       }}

+ 7 - 2
static/app/views/replays/replayTable/tableCell.tsx

@@ -43,7 +43,12 @@ type Props = {
   showDropdownFilters?: boolean;
 };
 
-export type ReferrerTableType = 'main' | 'dead-table' | 'errors-table' | 'rage-table';
+export type ReferrerTableType =
+  | 'main'
+  | 'dead-table'
+  | 'errors-table'
+  | 'rage-table'
+  | 'selector-widget';
 
 type EditType = 'set' | 'remove';
 
@@ -329,8 +334,8 @@ export function ReplayCell({
       case 'errors-table':
         return replayDetailsErrorTab;
       case 'dead-table':
-        return replayDetailsDOMEventsTab;
       case 'rage-table':
+      case 'selector-widget':
         return replayDetailsDOMEventsTab;
       default:
         return replayDetails;