Просмотр исходного кода

feat(replays): add table for selector list pages (#56339)

Display top selectors (50 per page) with rage/dead clicks in a table
format.
The link in the selector column just routes to the Replay index for now
but will eventually set the searchbar filters too.

<img width="1131" alt="SCR-20230915-ojdd"
src="https://github.com/getsentry/sentry/assets/56095982/57124992-7bfa-4d94-8fd8-e9d6b6c2fdd1">
<img width="1146" alt="SCR-20230915-ojah"
src="https://github.com/getsentry/sentry/assets/56095982/5899065b-37ee-4c04-9532-c9edad32f22e">


Also includes pagination:
<img width="1099" alt="SCR-20230915-ojji"
src="https://github.com/getsentry/sentry/assets/56095982/a3e3c2f9-da6e-4f67-874d-36812944b84c">


Closes https://github.com/getsentry/team-replay/issues/186 and closes
https://github.com/getsentry/team-replay/issues/178
Michelle Zhang 1 год назад
Родитель
Сommit
e8dab8ea94

+ 20 - 0
static/app/components/replays/utils.tsx

@@ -1,5 +1,6 @@
 import {formatSecondsToClock} from 'sentry/utils/formatters';
 import type {ReplayFrame, SpanFrame} from 'sentry/utils/replays/types';
+import {DeadRageSelectorItem} from 'sentry/views/replays/types';
 
 const SECOND = 1000;
 const MINUTE = 60 * SECOND;
@@ -186,3 +187,22 @@ export function divide(numerator: number, denominator: number | undefined) {
   }
   return numerator / denominator;
 }
+
+export function getAriaLabel(str: string) {
+  const pre = str.split('aria="')[1];
+  if (!pre) {
+    return '';
+  }
+  return pre.substring(0, pre.lastIndexOf('"]'));
+}
+
+export function hydratedSelectorData(data, clickType): DeadRageSelectorItem[] {
+  return data.map(d => {
+    return {
+      [clickType]: d[clickType],
+      dom_element: d.dom_element,
+      element: d.dom_element.split(/[#.]+/)[0],
+      aria_label: getAriaLabel(d.dom_element),
+    };
+  });
+}

+ 2 - 2
static/app/routes.tsx

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

+ 24 - 15
static/app/utils/replays/hooks/useDeadRageSelectors.tsx

@@ -6,24 +6,33 @@ import {
   DeadRageSelectorQueryParams,
 } from 'sentry/views/replays/types';
 
-export default function useRageDeadSelectors(
-  params: DeadRageSelectorQueryParams = {per_page: 10, sort: '-count_dead_clicks'}
-) {
+export default function useRageDeadSelectors(params: DeadRageSelectorQueryParams) {
   const organization = useOrganization();
   const location = useLocation();
   const {query} = location;
 
-  return useApiQuery<DeadRageSelectorListResponse>(
-    [
-      `/organizations/${organization.slug}/replay-selectors/`,
-      {
-        query: {
-          ...query,
-          per_page: params.per_page,
-          sort: params.sort,
+  const {isLoading, isError, data, getResponseHeader} =
+    useApiQuery<DeadRageSelectorListResponse>(
+      [
+        `/organizations/${organization.slug}/replay-selectors/`,
+        {
+          query: {
+            cursor: query.cursor,
+            environment: query.environment,
+            project: query.project,
+            statsPeriod: query.statsPeriod,
+            per_page: params.per_page,
+            sort: query.sort ?? params.sort,
+          },
         },
-      },
-    ],
-    {staleTime: Infinity}
-  );
+      ],
+      {staleTime: Infinity}
+    );
+
+  return {
+    isLoading,
+    isError,
+    data: data ? data.data : [],
+    pageLinks: getResponseHeader?.('Link') ?? undefined,
+  };
 }

+ 0 - 69
static/app/views/replays/deadClickList.tsx

@@ -1,69 +0,0 @@
-import Alert from 'sentry/components/alert';
-import * as Layout from 'sentry/components/layouts/thirds';
-import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
-import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
-import Placeholder from 'sentry/components/placeholder';
-import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-import {t} from 'sentry/locale';
-import useDeadRageSelectors from 'sentry/utils/replays/hooks/useDeadRageSelectors';
-import useOrganization from 'sentry/utils/useOrganization';
-
-export default function DeadClickList() {
-  const organization = useOrganization();
-  const hasDeadCicks = organization.features.includes(
-    'session-replay-rage-dead-selectors'
-  );
-  const {isLoading, isError, data} = useDeadRageSelectors({
-    per_page: 3,
-    sort: '-count_dead_clicks',
-  });
-
-  return hasDeadCicks ? (
-    <SentryDocumentTitle
-      title={t('Top Selectors with Dead Clicks')}
-      orgSlug={organization.slug}
-    >
-      <Layout.Header>
-        <Layout.HeaderContent>
-          <Layout.Title>
-            {t('Top Selectors with Dead Clicks')}
-            <PageHeadingQuestionTooltip
-              title={t('See the top selectors your users have dead 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>
-            {isLoading ? (
-              <Placeholder />
-            ) : isError ? (
-              <Alert type="error" showIcon>
-                {t('An error occurred')}
-              </Alert>
-            ) : (
-              <pre>
-                {JSON.stringify(
-                  data.data.map(d => {
-                    return {
-                      count_dead_clicks: d.count_dead_clicks,
-                      dom_element: d.dom_element,
-                    };
-                  }),
-                  null,
-                  '\t'
-                )}
-              </pre>
-            )}
-          </Layout.Main>
-        </Layout.Body>
-      </PageFiltersContainer>
-    </SentryDocumentTitle>
-  ) : (
-    <Layout.Page withPadding>
-      <Alert type="warning">{t("You don't have access to this feature")}</Alert>
-    </Layout.Page>
-  );
-}

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

@@ -0,0 +1,99 @@
+import {browserHistory, RouteComponentProps} 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 {hydratedSelectorData} from 'sentry/components/replays/utils';
+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 useOrganization from 'sentry/utils/useOrganization';
+import SelectorTable from 'sentry/views/replays/deadRageClick/selectorTable';
+import {DeadRageSelectorQueryParams} from 'sentry/views/replays/types';
+
+interface Props extends RouteComponentProps<{}, {}, DeadRageSelectorQueryParams> {}
+
+export default function DeadClickList({location}: Props) {
+  const organization = useOrganization();
+  const hasDeadClickFeature = organization.features.includes(
+    'session-replay-rage-dead-selectors'
+  );
+
+  const {isLoading, isError, data, pageLinks} = useDeadRageSelectors({
+    per_page: 50,
+    sort: '-count_dead_clicks',
+  });
+
+  if (!hasDeadClickFeature) {
+    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 Dead Clicks')}
+      orgSlug={organization.slug}
+    >
+      <Layout.Header>
+        <Layout.HeaderContent>
+          <Layout.Title>
+            {t('Top Selectors with Dead Clicks')}
+            <PageHeadingQuestionTooltip
+              title={t('See the top selectors your users have dead 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>
+            <LayoutGap>
+              <PageFilterBar condensed>
+                <ProjectPageFilter resetParamsOnChange={['cursor']} />
+                <EnvironmentPageFilter resetParamsOnChange={['cursor']} />
+                <DatePageFilter alignDropdown="left" resetParamsOnChange={['cursor']} />
+              </PageFilterBar>
+              <SelectorTable
+                data={hydratedSelectorData(data, 'count_dead_clicks')}
+                isError={isError}
+                isLoading={isLoading}
+                location={location}
+                clickCountColumn={{key: 'count_dead_clicks', name: 'dead 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(2)};
+`;
+
+const PaginationNoMargin = styled(Pagination)`
+  margin: 0;
+`;

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

@@ -0,0 +1,99 @@
+import {browserHistory, RouteComponentProps} 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 {hydratedSelectorData} from 'sentry/components/replays/utils';
+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 useOrganization from 'sentry/utils/useOrganization';
+import SelectorTable from 'sentry/views/replays/deadRageClick/selectorTable';
+import {DeadRageSelectorQueryParams} from 'sentry/views/replays/types';
+
+interface Props extends RouteComponentProps<{}, {}, DeadRageSelectorQueryParams> {}
+
+export default function RageClickList({location}: Props) {
+  const organization = useOrganization();
+  const hasRageClickFeature = organization.features.includes(
+    'session-replay-rage-dead-selectors'
+  );
+
+  const {isLoading, isError, data, pageLinks} = useDeadRageSelectors({
+    per_page: 50,
+    sort: '-count_rage_clicks',
+  });
+
+  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>
+            <LayoutGap>
+              <PageFilterBar condensed>
+                <ProjectPageFilter resetParamsOnChange={['cursor']} />
+                <EnvironmentPageFilter resetParamsOnChange={['cursor']} />
+                <DatePageFilter alignDropdown="left" resetParamsOnChange={['cursor']} />
+              </PageFilterBar>
+              <SelectorTable
+                data={hydratedSelectorData(data, 'count_rage_clicks')}
+                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(2)};
+`;
+
+const PaginationNoMargin = styled(Pagination)`
+  margin: 0;
+`;

+ 121 - 0
static/app/views/replays/deadRageClick/selectorTable.tsx

@@ -0,0 +1,121 @@
+import {useCallback, useMemo} from 'react';
+import type {Location} from 'history';
+
+import renderSortableHeaderCell from 'sentry/components/feedback/table/renderSortableHeaderCell';
+import useQueryBasedColumnResize from 'sentry/components/feedback/table/useQueryBasedColumnResize';
+import useQueryBasedSorting from 'sentry/components/feedback/table/useQueryBasedSorting';
+import GridEditable, {GridColumnOrder} from 'sentry/components/gridEditable';
+import Link from 'sentry/components/links/link';
+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';
+
+interface UrlState {
+  widths: string[];
+}
+
+interface Props {
+  clickCountColumn: {key: string; name: string};
+  data: DeadRageSelectorItem[];
+  isError: boolean;
+  isLoading: boolean;
+  location: Location<DeadRageSelectorQueryParams & UrlState>;
+}
+
+const BASE_COLUMNS: GridColumnOrder<string>[] = [
+  {key: 'element', name: 'element'},
+  {key: 'dom_element', name: 'selector'},
+  {key: 'aria_label', name: 'aria label'},
+];
+
+export default function SelectorTable({
+  clickCountColumn,
+  isError,
+  isLoading,
+  data,
+  location,
+}: Props) {
+  const organization = useOrganization();
+
+  const {currentSort, makeSortLinkGenerator} = useQueryBasedSorting({
+    defaultSort: {field: clickCountColumn.key, kind: 'desc'},
+    location,
+  });
+
+  const {columns, handleResizeColumn} = useQueryBasedColumnResize({
+    columns: BASE_COLUMNS.concat(clickCountColumn),
+    location,
+  });
+
+  const renderHeadCell = useMemo(
+    () =>
+      renderSortableHeaderCell({
+        currentSort,
+        makeSortLinkGenerator,
+        onClick: () => {},
+        rightAlignedColumns: [],
+        sortableColumns: [clickCountColumn],
+      }),
+    [clickCountColumn, currentSort, makeSortLinkGenerator]
+  );
+
+  const renderBodyCell = useCallback(
+    (column, dataRow) => {
+      const value = dataRow[column.key];
+      switch (column.key) {
+        case 'dom_element':
+          return <SelectorLink organization={organization} value={value} />;
+        case 'element':
+          return <code>{value}</code>;
+        case 'aria_label':
+          return <code>{value}</code>;
+        default:
+          return renderSimpleBodyCell<DeadRageSelectorItem>(column, dataRow);
+      }
+    },
+    [organization]
+  );
+
+  return (
+    <GridEditable
+      error={isError}
+      isLoading={isLoading}
+      data={data ?? []}
+      columnOrder={columns}
+      columnSortBy={[]}
+      stickyHeader
+      grid={{
+        onResizeColumn: handleResizeColumn,
+        renderHeadCell,
+        renderBodyCell,
+      }}
+      location={location as Location<any>}
+    />
+  );
+}
+
+function SelectorLink({
+  organization,
+  value,
+}: {
+  organization: Organization;
+  value: string;
+}) {
+  return (
+    <Link
+      to={{
+        pathname: normalizeUrl(`/organizations/${organization.slug}/replays/`),
+      }}
+    >
+      {value}
+    </Link>
+  );
+}
+
+function renderSimpleBodyCell<T>(column: GridColumnOrder<string>, dataRow: T) {
+  return dataRow[column.key];
+}

+ 0 - 69
static/app/views/replays/rageClickList.tsx

@@ -1,69 +0,0 @@
-import Alert from 'sentry/components/alert';
-import * as Layout from 'sentry/components/layouts/thirds';
-import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
-import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
-import Placeholder from 'sentry/components/placeholder';
-import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-import {t} from 'sentry/locale';
-import useDeadRageSelectors from 'sentry/utils/replays/hooks/useDeadRageSelectors';
-import useOrganization from 'sentry/utils/useOrganization';
-
-export default function RageClickList() {
-  const organization = useOrganization();
-  const hasRageCicks = organization.features.includes(
-    'session-replay-rage-dead-selectors'
-  );
-  const {isLoading, isError, data} = useDeadRageSelectors({
-    per_page: 3,
-    sort: '-count_rage_clicks',
-  });
-
-  return hasRageCicks ? (
-    <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>
-            {isLoading ? (
-              <Placeholder />
-            ) : isError ? (
-              <Alert type="error" showIcon>
-                {t('An error occurred')}
-              </Alert>
-            ) : (
-              <pre>
-                {JSON.stringify(
-                  data.data.map(d => {
-                    return {
-                      count_rage_clicks: d.count_rage_clicks,
-                      dom_element: d.dom_element,
-                    };
-                  }),
-                  null,
-                  '\t'
-                )}
-              </pre>
-            )}
-          </Layout.Main>
-        </Layout.Body>
-      </PageFiltersContainer>
-    </SentryDocumentTitle>
-  ) : (
-    <Layout.Page withPadding>
-      <Alert type="warning">{t("You don't have access to this feature")}</Alert>
-    </Layout.Page>
-  );
-}

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

@@ -169,9 +169,11 @@ export interface ReplayError {
 }
 
 export type DeadRageSelectorItem = {
-  count_dead_clicks: number;
-  count_rage_clicks: number;
+  aria_label: string;
   dom_element: string;
+  element: string;
+  count_dead_clicks?: number;
+  count_rage_clicks?: number;
 };
 
 export type DeadRageSelectorListResponse = {