Browse Source

feat(replays): add tabs to replay index (#56959)

Michelle Zhang 1 year ago
parent
commit
48cafa6204

+ 4 - 6
static/app/routes.tsx

@@ -1375,12 +1375,10 @@ function buildRoutes() {
     <Fragment>
       <IndexRoute component={make(() => import('sentry/views/replays/list'))} />
       <Route
-        path="dead-clicks/"
-        component={make(() => import('sentry/views/replays/deadRageClick/deadClickList'))}
-      />
-      <Route
-        path="rage-clicks/"
-        component={make(() => import('sentry/views/replays/deadRageClick/rageClickList'))}
+        path="selectors/"
+        component={make(
+          () => import('sentry/views/replays/deadRageClick/deadRageClickList')
+        )}
       />
       <Route
         path=":replaySlug/"

+ 4 - 1
static/app/utils/replays/hooks/useDeadRageSelectors.tsx

@@ -33,7 +33,10 @@ export default function useDeadRageSelectors(params: DeadRageSelectorQueryParams
   return {
     isLoading,
     isError,
-    data: hydratedSelectorData(data ? data.data : [], params.sort?.replace(/^-/, '')),
+    data: hydratedSelectorData(
+      data ? data.data : [],
+      params.isWidgetData ? params.sort?.replace(/^-/, '') : null
+    ),
     pageLinks: getResponseHeader?.('Link') ?? undefined,
   };
 }

+ 13 - 6
static/app/views/replays/deadRageClick/deadClickList.tsx → static/app/views/replays/deadRageClick/deadRageClickList.tsx

@@ -17,8 +17,9 @@ import useDeadRageSelectors from 'sentry/utils/replays/hooks/useDeadRageSelector
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import SelectorTable from 'sentry/views/replays/deadRageClick/selectorTable';
+import ReplayTabs from 'sentry/views/replays/tabs';
 
-export default function DeadClickList() {
+export default function DeadRageClickList() {
   const organization = useOrganization();
   const location = useLocation();
   const hasDeadClickFeature = organization.features.includes(
@@ -30,6 +31,7 @@ export default function DeadClickList() {
     sort: '-count_dead_clicks',
     cursor: location.query.cursor,
     prefix: '',
+    isWidgetData: false,
   });
 
   if (!hasDeadClickFeature) {
@@ -48,13 +50,15 @@ export default function DeadClickList() {
       <Layout.Header>
         <Layout.HeaderContent>
           <Layout.Title>
-            {t('Top Selectors with Dead Clicks')}
+            {t('Top Selectors with Dead and Rage Clicks')}
             <PageHeadingQuestionTooltip
-              title={t('See the top selectors your users have dead clicked on.')}
+              title={t('See the top selectors your users have dead and rage clicked on.')}
               docsUrl="https://docs.sentry.io/product/session-replay/replay-page-and-filters/"
             />
           </Layout.Title>
         </Layout.HeaderContent>
+        <div /> {/* wraps the tabs below the page title */}
+        <ReplayTabs selected="selectors" />
       </Layout.Header>
       <PageFiltersContainer>
         <Layout.Body>
@@ -70,7 +74,11 @@ export default function DeadClickList() {
                 isError={isError}
                 isLoading={isLoading}
                 location={location}
-                clickCountColumn={{key: 'count_dead_clicks', name: 'dead clicks'}}
+                clickCountColumns={[
+                  {key: 'count_dead_clicks', name: 'dead clicks'},
+                  {key: 'count_rage_clicks', name: 'rage clicks'},
+                ]}
+                clickCountSortable
               />
             </LayoutGap>
             <PaginationNoMargin
@@ -90,8 +98,7 @@ export default function DeadClickList() {
 }
 
 const LayoutGap = styled('div')`
-  display: grid;
-  gap: ${space(1)};
+  margin-top: ${space(2)};
 `;
 
 const PaginationNoMargin = styled(Pagination)`

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

@@ -1,15 +1,12 @@
-import {ComponentProps, Fragment, ReactNode} from 'react';
+import {Fragment} from 'react';
 import styled from '@emotion/styled';
 import {Location} from 'history';
 
-import {LinkButton} from 'sentry/components/button';
-import {IconCursorArrow, IconShow} from 'sentry/icons';
+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 {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() {
@@ -29,15 +26,16 @@ function DeadClickTable({location}: {location: Location<any>}) {
     sort: '-count_dead_clicks',
     cursor: undefined,
     prefix: 'selector_',
+    isWidgetData: true,
   });
 
   return (
     <SelectorTable
-      data={data}
+      data={data.filter(d => (d.count_dead_clicks ?? 0) > 0)}
       isError={isError}
       isLoading={isLoading}
       location={location}
-      clickCountColumn={{key: 'count_dead_clicks', name: 'dead clicks'}}
+      clickCountColumns={[{key: 'count_dead_clicks', name: 'dead clicks'}]}
       title={
         <Fragment>
           <IconContainer>
@@ -46,14 +44,8 @@ function DeadClickTable({location}: {location: Location<any>}) {
           {t('Most Dead Clicks')}
         </Fragment>
       }
-      headerButtons={
-        <SearchButton
-          label={t('Show all')}
-          sort="-count_dead_clicks"
-          path="dead-clicks"
-        />
-      }
       customHandleResize={() => {}}
+      clickCountSortable={false}
     />
   );
 }
@@ -64,15 +56,16 @@ function RageClickTable({location}: {location: Location<any>}) {
     sort: '-count_rage_clicks',
     cursor: undefined,
     prefix: 'selector_',
+    isWidgetData: true,
   });
 
   return (
     <SelectorTable
-      data={data}
+      data={data.filter(d => (d.count_rage_clicks ?? 0) > 0)}
       isError={isError}
       isLoading={isLoading}
       location={location}
-      clickCountColumn={{key: 'count_rage_clicks', name: 'rage clicks'}}
+      clickCountColumns={[{key: 'count_rage_clicks', name: 'rage clicks'}]}
       title={
         <Fragment>
           <IconContainer>
@@ -81,51 +74,12 @@ function RageClickTable({location}: {location: Location<any>}) {
           {t('Most Rage Clicks')}
         </Fragment>
       }
-      headerButtons={
-        <SearchButton
-          label={t('Show all')}
-          sort="-count_rage_clicks"
-          path="rage-clicks"
-        />
-      }
       customHandleResize={() => {}}
+      clickCountSortable={false}
     />
   );
 }
 
-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="xs"
-      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;

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

@@ -1,99 +0,0 @@
-import {browserHistory} from 'react-router';
-import styled from '@emotion/styled';
-
-import Alert from 'sentry/components/alert';
-import DatePageFilter from 'sentry/components/datePageFilter';
-import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
-import * as Layout from 'sentry/components/layouts/thirds';
-import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
-import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
-import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
-import Pagination from 'sentry/components/pagination';
-import ProjectPageFilter from 'sentry/components/projectPageFilter';
-import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-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 SelectorTable from 'sentry/views/replays/deadRageClick/selectorTable';
-
-export default function RageClickList() {
-  const organization = useOrganization();
-  const location = useLocation();
-  const hasRageClickFeature = organization.features.includes(
-    'session-replay-rage-dead-selectors'
-  );
-
-  const {isLoading, isError, data, pageLinks} = useDeadRageSelectors({
-    per_page: 50,
-    sort: '-count_rage_clicks',
-    cursor: location.query.cursor,
-    prefix: '',
-  });
-
-  if (!hasRageClickFeature) {
-    return (
-      <Layout.Page withPadding>
-        <Alert type="warning">{t("You don't have access to this feature")}</Alert>
-      </Layout.Page>
-    );
-  }
-
-  return (
-    <SentryDocumentTitle
-      title={t('Top Selectors with Rage Clicks')}
-      orgSlug={organization.slug}
-    >
-      <Layout.Header>
-        <Layout.HeaderContent>
-          <Layout.Title>
-            {t('Top Selectors with Rage Clicks')}
-            <PageHeadingQuestionTooltip
-              title={t('See the top selectors your users have rage clicked on.')}
-              docsUrl="https://docs.sentry.io/product/session-replay/replay-page-and-filters/"
-            />
-          </Layout.Title>
-        </Layout.HeaderContent>
-      </Layout.Header>
-      <PageFiltersContainer>
-        <Layout.Body>
-          <Layout.Main fullWidth>
-            <PageFilterBar condensed>
-              <ProjectPageFilter resetParamsOnChange={['cursor']} />
-              <EnvironmentPageFilter resetParamsOnChange={['cursor']} />
-              <DatePageFilter alignDropdown="left" resetParamsOnChange={['cursor']} />
-            </PageFilterBar>
-            <LayoutGap>
-              <SelectorTable
-                data={data}
-                isError={isError}
-                isLoading={isLoading}
-                location={location}
-                clickCountColumn={{key: 'count_rage_clicks', name: 'rage clicks'}}
-              />
-            </LayoutGap>
-            <PaginationNoMargin
-              pageLinks={pageLinks}
-              onCursor={(cursor, path, searchQuery) => {
-                browserHistory.push({
-                  pathname: path,
-                  query: {...searchQuery, cursor},
-                });
-              }}
-            />
-          </Layout.Main>
-        </Layout.Body>
-      </PageFiltersContainer>
-    </SentryDocumentTitle>
-  );
-}
-
-const LayoutGap = styled('div')`
-  display: grid;
-  gap: ${space(1)};
-`;
-
-const PaginationNoMargin = styled(Pagination)`
-  margin: 0;
-`;

+ 42 - 22
static/app/views/replays/deadRageClick/selectorTable.tsx

@@ -8,6 +8,8 @@ import useQueryBasedSorting from 'sentry/components/feedback/table/useQueryBased
 import GridEditable, {GridColumnOrder} from 'sentry/components/gridEditable';
 import Link from 'sentry/components/links/link';
 import TextOverflow from 'sentry/components/textOverflow';
+import {IconCursorArrow} from 'sentry/icons';
+import {space} from 'sentry/styles/space';
 import {Organization} from 'sentry/types';
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
@@ -25,27 +27,28 @@ export function getAriaLabel(str: string) {
   return pre.substring(0, pre.lastIndexOf('"]'));
 }
 
-export function hydratedSelectorData(data, clickType): DeadRageSelectorItem[] {
-  return data
-    .filter(d => d[clickType] > 0)
-    .map(d => {
-      return {
-        [clickType]: d[clickType],
-        dom_element: d.dom_element,
-        element: d.dom_element.split(/[#.]+/)[0],
-        aria_label: getAriaLabel(d.dom_element),
-      };
-    });
+export function hydratedSelectorData(data, clickType?): DeadRageSelectorItem[] {
+  return data.map(d => ({
+    ...(clickType
+      ? {[clickType]: d[clickType]}
+      : {
+          count_dead_clicks: d.count_dead_clicks,
+          count_rage_clicks: d.count_rage_clicks,
+        }),
+    dom_element: d.dom_element,
+    element: d.dom_element.split(/[#.]+/)[0],
+    aria_label: getAriaLabel(d.dom_element),
+  }));
 }
 
 interface Props {
-  clickCountColumn: {key: string; name: string};
+  clickCountColumns: {key: string; name: string}[];
+  clickCountSortable: boolean;
   data: DeadRageSelectorItem[];
   isError: boolean;
   isLoading: boolean;
   location: Location<any>;
   customHandleResize?: () => void;
-  headerButtons?: ReactNode;
   title?: ReactNode;
 }
 
@@ -56,24 +59,24 @@ const BASE_COLUMNS: GridColumnOrder<string>[] = [
 ];
 
 export default function SelectorTable({
-  clickCountColumn,
+  clickCountColumns,
   data,
   isError,
   isLoading,
   location,
   title,
-  headerButtons,
   customHandleResize,
+  clickCountSortable,
 }: Props) {
   const organization = useOrganization();
 
   const {currentSort, makeSortLinkGenerator} = useQueryBasedSorting({
-    defaultSort: {field: clickCountColumn.key, kind: 'desc'},
+    defaultSort: {field: clickCountColumns[0].key, kind: 'desc'},
     location,
   });
 
   const {columns, handleResizeColumn} = useQueryBasedColumnResize({
-    columns: BASE_COLUMNS.concat(clickCountColumn),
+    columns: BASE_COLUMNS.concat(clickCountColumns),
     location,
   });
 
@@ -84,9 +87,9 @@ export default function SelectorTable({
         makeSortLinkGenerator,
         onClick: () => {},
         rightAlignedColumns: [],
-        sortableColumns: [],
+        sortableColumns: clickCountSortable ? clickCountColumns : [],
       }),
-    [currentSort, makeSortLinkGenerator]
+    [currentSort, makeSortLinkGenerator, clickCountColumns, clickCountSortable]
   );
 
   const renderBodyCell = useCallback(
@@ -124,7 +127,6 @@ export default function SelectorTable({
       }}
       location={location as Location<any>}
       title={title}
-      headerButtons={() => headerButtons}
     />
   );
 }
@@ -149,10 +151,24 @@ function SelectorLink({
 
 function renderSimpleBodyCell<T>(column: GridColumnOrder<string>, dataRow: T) {
   if (column.key === 'count_dead_clicks') {
-    return <DeadClickCount>{dataRow[column.key]}</DeadClickCount>;
+    return (
+      <DeadClickCount>
+        <IconContainer>
+          <IconCursorArrow size="xs" />
+        </IconContainer>
+        {dataRow[column.key]}
+      </DeadClickCount>
+    );
   }
   if (column.key === 'count_rage_clicks') {
-    return <RageClickCount>{dataRow[column.key]}</RageClickCount>;
+    return (
+      <RageClickCount>
+        <IconContainer>
+          <IconCursorArrow size="xs" />
+        </IconContainer>
+        {dataRow[column.key]}
+      </RageClickCount>
+    );
   }
   return <TextOverflow>{dataRow[column.key]}</TextOverflow>;
 }
@@ -164,3 +180,7 @@ const DeadClickCount = styled(TextOverflow)`
 const RageClickCount = styled(TextOverflow)`
   color: ${p => p.theme.red300};
 `;
+
+const IconContainer = styled('span')`
+  margin-right: ${space(1)};
+`;

+ 3 - 0
static/app/views/replays/list.tsx

@@ -11,6 +11,7 @@ import {space} from 'sentry/styles/space';
 import useReplayPageview from 'sentry/utils/replays/hooks/useReplayPageview';
 import useOrganization from 'sentry/utils/useOrganization';
 import ListContent from 'sentry/views/replays/list/listContent';
+import ReplayTabs from 'sentry/views/replays/tabs';
 
 const ReplayListPageHeaderHook = HookOrDefault({
   hookName: 'component:replay-list-page-header',
@@ -35,6 +36,8 @@ function ReplaysListContainer() {
             />
           </Layout.Title>
         </Layout.HeaderContent>
+        <div /> {/* wraps the tabs below the page title */}
+        <ReplayTabs selected="replays" />
       </Layout.Header>
       <PageFiltersContainer>
         <Layout.Body>

+ 36 - 0
static/app/views/replays/tabs.tsx

@@ -0,0 +1,36 @@
+import {TabList, Tabs} from 'sentry/components/tabs';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface Props {
+  selected: 'replays' | 'selectors';
+}
+
+const SELECTOR_IDX_ROUTE = 'selectors';
+const REPLAY_IDX_ROUTE = '';
+
+const TABS = [
+  {key: 'replays', label: 'Replays', to: REPLAY_IDX_ROUTE},
+  {key: 'selectors', label: 'Selectors', to: SELECTOR_IDX_ROUTE},
+];
+
+export default function ReplayTabs({selected}: Props) {
+  const organization = useOrganization();
+  const hasDeadClickFeature = organization.features.includes(
+    'session-replay-rage-dead-selectors'
+  );
+
+  return hasDeadClickFeature ? (
+    <Tabs value={selected}>
+      <TabList hideBorder>
+        {TABS.map(tab => (
+          <TabList.Item
+            key={tab.key}
+            to={`/organizations/${organization.slug}/replays/${tab.to}`}
+          >
+            {tab.label}
+          </TabList.Item>
+        ))}
+      </TabList>
+    </Tabs>
+  ) : null;
+}

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

@@ -181,6 +181,7 @@ export type DeadRageSelectorListResponse = {
 };
 
 export interface DeadRageSelectorQueryParams {
+  isWidgetData: boolean;
   cursor?: string | string[] | undefined | null;
   per_page?: number;
   prefix?: string;