Browse Source

feat(replays): add selector widgets to replay index (#56425)

Replace the widgets on the replay index page with widgets showing the
top 3 selectors with rage/dead clicks.



https://github.com/getsentry/sentry/assets/56095982/3482c0f3-d1cf-4ed2-ab78-7d2b6c2389a6


<img width="1136" alt="SCR-20230918-nppu"
src="https://github.com/getsentry/sentry/assets/56095982/b5828671-5ff2-4a7d-9ecf-cfc871e84905">

 
Closes https://github.com/getsentry/team-replay/issues/184 and closes
https://github.com/getsentry/team-replay/issues/185.


**what currently works:**
- proj, env, date filters apply to the widgets
- show all button 
- the "show all" button currently routes to the appropriate selector
list index page created in
https://github.com/getsentry/sentry/pull/56339, but this is subject to
change if we decide to add tabs to the index page instead.
- entering search terms, sorting, or pagination of big replay list does
not affect the widgets (desired behavior)

**what currently doesn’t work or what isn't implemented:**
- resizing widget column size causes the big replay list to re-render
because it updates location. we don't want this behavior on the replay
index, but we _do_ use this behavior in the selector indexes. to get
around this, i created a custom `handleResizeColumn` for these widgets
(that essentially does nothing) --> this could be improved
- when we have the functionality to search on rage and dead clicks, we
can add a column with little search icons, per this design:
<img width="368" alt="SCR-20230918-nens"
src="https://github.com/getsentry/sentry/assets/56095982/74102874-ed56-41ad-a487-66a0d9dd216c">
Michelle Zhang 1 year ago
parent
commit
5315042579

+ 2 - 2
static/app/utils/replays/hooks/useDeadRageSelectors.tsx

@@ -17,12 +17,12 @@ export default function useRageDeadSelectors(params: DeadRageSelectorQueryParams
         `/organizations/${organization.slug}/replay-selectors/`,
         {
           query: {
-            cursor: query.cursor,
+            cursor: params.cursor,
             environment: query.environment,
             project: query.project,
             statsPeriod: query.statsPeriod,
             per_page: params.per_page,
-            sort: query.sort ?? params.sort,
+            sort: query[params.prefix + 'sort'] ?? params.sort,
           },
         },
       ],

+ 3 - 0
static/app/views/replays/deadRageClick/deadClickList.tsx

@@ -30,6 +30,8 @@ export default function DeadClickList({location}: Props) {
   const {isLoading, isError, data, pageLinks} = useDeadRageSelectors({
     per_page: 50,
     sort: '-count_dead_clicks',
+    cursor: location.query.cursor,
+    prefix: '',
   });
 
   if (!hasDeadClickFeature) {
@@ -71,6 +73,7 @@ export default function DeadClickList({location}: Props) {
                 isLoading={isLoading}
                 location={location}
                 clickCountColumn={{key: 'count_dead_clicks', name: 'dead clicks'}}
+                clickCountSortable
               />
             </LayoutGap>
             <PaginationNoMargin

+ 128 - 0
static/app/views/replays/deadRageClick/deadRageSelectorCards.tsx

@@ -0,0 +1,128 @@
+import {ComponentProps, ReactNode} from 'react';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import {LinkButton} from 'sentry/components/button';
+import {hydratedSelectorData} from 'sentry/components/replays/utils';
+import {IconShow} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import useDeadRageSelectors from 'sentry/utils/replays/hooks/useDeadRageSelectors';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import SelectorTable from 'sentry/views/replays/deadRageClick/selectorTable';
+
+function DeadRageSelectorCards() {
+  const location = useLocation();
+
+  return (
+    <SplitCardContainer>
+      <DeadClickTable location={location} />
+      <RageClickTable location={location} />
+    </SplitCardContainer>
+  );
+}
+
+function DeadClickTable({location}: {location: Location<any>}) {
+  const {isLoading, isError, data} = useDeadRageSelectors({
+    per_page: 3,
+    sort: '-count_dead_clicks',
+    cursor: undefined,
+    prefix: 'selector_',
+  });
+
+  return (
+    <SelectorTable
+      data={hydratedSelectorData(data, 'count_dead_clicks')}
+      isError={isError}
+      isLoading={isLoading}
+      location={location}
+      clickCountColumn={{key: 'count_dead_clicks', name: 'dead clicks'}}
+      clickCountSortable={false}
+      title={t('Most Dead Clicks')}
+      headerButtons={
+        <SearchButton
+          label={t('Show all')}
+          sort="-count_dead_clicks"
+          path="dead-clicks"
+        />
+      }
+      customHandleResize={() => {}}
+    />
+  );
+}
+
+function RageClickTable({location}: {location: Location<any>}) {
+  const {isLoading, isError, data} = useDeadRageSelectors({
+    per_page: 3,
+    sort: '-count_rage_clicks',
+    cursor: undefined,
+    prefix: 'selector_',
+  });
+
+  return (
+    <SelectorTable
+      data={hydratedSelectorData(data, 'count_rage_clicks')}
+      isError={isError}
+      isLoading={isLoading}
+      location={location}
+      clickCountColumn={{key: 'count_rage_clicks', name: 'rage clicks'}}
+      clickCountSortable={false}
+      title={t('Most Rage Clicks')}
+      headerButtons={
+        <SearchButton
+          label={t('Show all')}
+          sort="-count_rage_clicks"
+          path="rage-clicks"
+        />
+      }
+      customHandleResize={() => {}}
+    />
+  );
+}
+
+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 (
+    <LinkButton
+      {...props}
+      size="sm"
+      to={{
+        pathname: normalizeUrl(`/organizations/${organization.slug}/replays/${path}/`),
+        query: {
+          ...location.query,
+          sort,
+          query: undefined,
+          cursor: undefined,
+        },
+      }}
+      icon={<IconShow size="xs" />}
+    >
+      {label}
+    </LinkButton>
+  );
+}
+
+const SplitCardContainer = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-template-rows: max-content max-content;
+  grid-auto-flow: column;
+  gap: 0 ${space(2)};
+  align-items: stretch;
+  padding-top: ${space(1)};
+`;
+
+export default DeadRageSelectorCards;

+ 3 - 0
static/app/views/replays/deadRageClick/rageClickList.tsx

@@ -30,6 +30,8 @@ export default function RageClickList({location}: Props) {
   const {isLoading, isError, data, pageLinks} = useDeadRageSelectors({
     per_page: 50,
     sort: '-count_rage_clicks',
+    cursor: location.query.cursor,
+    prefix: '',
   });
 
   if (!hasRageClickFeature) {
@@ -71,6 +73,7 @@ export default function RageClickList({location}: Props) {
                 isLoading={isLoading}
                 location={location}
                 clickCountColumn={{key: 'count_rage_clicks', name: 'rage clicks'}}
+                clickCountSortable
               />
             </LayoutGap>
             <PaginationNoMargin

+ 41 - 28
static/app/views/replays/deadRageClick/selectorTable.tsx

@@ -1,4 +1,4 @@
-import {useCallback, useMemo} from 'react';
+import {Fragment, ReactNode, useCallback, useMemo} from 'react';
 import type {Location} from 'history';
 
 import renderSortableHeaderCell from 'sentry/components/feedback/table/renderSortableHeaderCell';
@@ -6,24 +6,26 @@ import useQueryBasedColumnResize from 'sentry/components/feedback/table/useQuery
 import useQueryBasedSorting from 'sentry/components/feedback/table/useQueryBasedSorting';
 import GridEditable, {GridColumnOrder} from 'sentry/components/gridEditable';
 import Link from 'sentry/components/links/link';
+import TextOverflow from 'sentry/components/textOverflow';
 import {Organization} from 'sentry/types';
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
-import {
-  DeadRageSelectorItem,
-  DeadRageSelectorQueryParams,
-} from 'sentry/views/replays/types';
+import {DeadRageSelectorItem} from 'sentry/views/replays/types';
 
-interface UrlState {
+export interface UrlState {
   widths: string[];
 }
 
 interface Props {
   clickCountColumn: {key: string; name: string};
+  clickCountSortable: boolean;
   data: DeadRageSelectorItem[];
   isError: boolean;
   isLoading: boolean;
-  location: Location<DeadRageSelectorQueryParams & UrlState>;
+  location: Location<any>;
+  customHandleResize?: () => void;
+  headerButtons?: ReactNode;
+  title?: string;
 }
 
 const BASE_COLUMNS: GridColumnOrder<string>[] = [
@@ -34,10 +36,14 @@ const BASE_COLUMNS: GridColumnOrder<string>[] = [
 
 export default function SelectorTable({
   clickCountColumn,
+  clickCountSortable,
+  data,
   isError,
   isLoading,
-  data,
   location,
+  title,
+  headerButtons,
+  customHandleResize,
 }: Props) {
   const organization = useOrganization();
 
@@ -58,9 +64,9 @@ export default function SelectorTable({
         makeSortLinkGenerator,
         onClick: () => {},
         rightAlignedColumns: [],
-        sortableColumns: [clickCountColumn],
+        sortableColumns: clickCountSortable ? [clickCountColumn] : [],
       }),
-    [clickCountColumn, currentSort, makeSortLinkGenerator]
+    [clickCountColumn, currentSort, makeSortLinkGenerator, clickCountSortable]
   );
 
   const renderBodyCell = useCallback(
@@ -70,9 +76,12 @@ export default function SelectorTable({
         case 'dom_element':
           return <SelectorLink organization={organization} value={value} />;
         case 'element':
-          return <code>{value}</code>;
         case 'aria_label':
-          return <code>{value}</code>;
+          return (
+            <code>
+              <TextOverflow>{value}</TextOverflow>
+            </code>
+          );
         default:
           return renderSimpleBodyCell<DeadRageSelectorItem>(column, dataRow);
       }
@@ -81,20 +90,24 @@ export default function SelectorTable({
   );
 
   return (
-    <GridEditable
-      error={isError}
-      isLoading={isLoading}
-      data={data ?? []}
-      columnOrder={columns}
-      columnSortBy={[]}
-      stickyHeader
-      grid={{
-        onResizeColumn: handleResizeColumn,
-        renderHeadCell,
-        renderBodyCell,
-      }}
-      location={location as Location<any>}
-    />
+    <Fragment>
+      <GridEditable
+        error={isError}
+        isLoading={isLoading}
+        data={data ?? []}
+        columnOrder={columns}
+        columnSortBy={[]}
+        stickyHeader
+        grid={{
+          onResizeColumn: customHandleResize ?? handleResizeColumn,
+          renderHeadCell,
+          renderBodyCell,
+        }}
+        location={location as Location<any>}
+        title={title}
+        headerButtons={() => headerButtons}
+      />
+    </Fragment>
   );
 }
 
@@ -111,11 +124,11 @@ function SelectorLink({
         pathname: normalizeUrl(`/organizations/${organization.slug}/replays/`),
       }}
     >
-      {value}
+      <TextOverflow>{value}</TextOverflow>
     </Link>
   );
 }
 
 function renderSimpleBodyCell<T>(column: GridColumnOrder<string>, dataRow: T) {
-  return dataRow[column.key];
+  return <TextOverflow>{dataRow[column.key]}</TextOverflow>;
 }

+ 9 - 3
static/app/views/replays/list/listContent.tsx

@@ -9,6 +9,7 @@ import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyti
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import useProjectSdkNeedsUpdate from 'sentry/utils/useProjectSdkNeedsUpdate';
+import DeadRageSelectorCards from 'sentry/views/replays/deadRageClick/deadRageSelectorCards';
 import ReplaysFilters from 'sentry/views/replays/list/filters';
 import ReplayOnboardingPanel from 'sentry/views/replays/list/replayOnboardingPanel';
 import ReplaysErroneousDeadRageCards from 'sentry/views/replays/list/replaysErroneousDeadRageCards';
@@ -17,10 +18,11 @@ import ReplaysSearch from 'sentry/views/replays/list/search';
 
 export default function ListContent() {
   const organization = useOrganization();
-
   const hasSessionReplay = organization.features.includes('session-replay');
-
   const hasSentReplays = useHaveSelectedProjectsSentAnyReplayEvents();
+  const hasdeadRageClickFeature = organization.features.includes(
+    'session-replay-rage-dead-selectors'
+  );
 
   const {
     selection: {projects},
@@ -72,7 +74,11 @@ export default function ListContent() {
         <ReplaysFilters />
         <ReplaysSearch />
       </FiltersContainer>
-      <ReplaysErroneousDeadRageCards />
+      {hasdeadRageClickFeature ? (
+        <DeadRageSelectorCards />
+      ) : (
+        <ReplaysErroneousDeadRageCards />
+      )}
       <ReplaysList />
     </Fragment>
   );

+ 2 - 0
static/app/views/replays/types.tsx

@@ -181,7 +181,9 @@ export type DeadRageSelectorListResponse = {
 };
 
 export interface DeadRageSelectorQueryParams {
+  cursor?: string | undefined;
   per_page?: number;
+  prefix?: string;
   sort?:
     | 'count_dead_clicks'
     | '-count_dead_clicks'