Browse Source

ref(replays): Refactor the ReplayTable to make it more reusable (#42857)

Refactor the ReplayTable so it's more re-usable for the upcoming `Other
Session` tab that's going into the replay details page.


Previously we had this pattern of setting `showFieldXYZ` props whenever
we wanted to see a column or not. But only some columns were guarded by
that, they were also guarded by the total page width. This combo made it
hard to reason about what column was going to be shown, and if list were
put into a container that's smaller than the window we would not get
those columns hidden.

So I created the new `VisibleColumns` type and props so each callsite
can be explicit about which columns it wants to see, then i hoisted up
the media query check into all the existing callsites, so they can
continue to work the way they have before.


We're left with the new ReplayTable, which is mostly concerned with
sorting out which columns to show, then delegates to HeaderCell and
TableCell to get those columns rendered.

Fixes #40726
Ryan Albrecht 2 years ago
parent
commit
67e85043ce

+ 23 - 4
static/app/views/organizationGroupDetails/groupReplays/groupReplays.tsx

@@ -1,4 +1,5 @@
 import {useCallback, useEffect, useMemo, useState} from 'react';
+import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import * as Sentry from '@sentry/react';
 import {Location} from 'history';
@@ -16,8 +17,10 @@ import useReplayList from 'sentry/utils/replays/hooks/useReplayList';
 import useApi from 'sentry/utils/useApi';
 import useCleanQueryParamsOnRouteLeave from 'sentry/utils/useCleanQueryParamsOnRouteLeave';
 import {useLocation} from 'sentry/utils/useLocation';
+import useMedia from 'sentry/utils/useMedia';
 import useOrganization from 'sentry/utils/useOrganization';
 import ReplayTable from 'sentry/views/replays/replayTable';
+import {ReplayColumns} from 'sentry/views/replays/replayTable/types';
 import type {ReplayListLocationQuery} from 'sentry/views/replays/types';
 
 type Props = {
@@ -28,6 +31,8 @@ function GroupReplays({group}: Props) {
   const api = useApi();
   const location = useLocation<ReplayListLocationQuery>();
   const organization = useOrganization();
+  const theme = useTheme();
+  const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.small})`);
 
   const [response, setResponse] = useState<{
     pageLinks: null | string;
@@ -90,11 +95,17 @@ function GroupReplays({group}: Props) {
     return (
       <StyledPageContent>
         <ReplayTable
+          fetchError={fetchError}
           isFetching
           replays={[]}
-          showProjectColumn={false}
           sort={undefined}
-          fetchError={fetchError}
+          visibleColumns={[
+            ReplayColumns.session,
+            ...(hasRoomForColumns ? [ReplayColumns.startedAt] : []),
+            ReplayColumns.duration,
+            ReplayColumns.countErrors,
+            ReplayColumns.activity,
+          ]}
         />
         <Pagination pageLinks={null} />
       </StyledPageContent>
@@ -119,6 +130,8 @@ const GroupReplaysTable = ({
   pageLinks: string | null;
 }) => {
   const location = useMemo(() => ({query: {}} as Location<ReplayListLocationQuery>), []);
+  const theme = useTheme();
+  const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.small})`);
 
   const {replays, isFetching, fetchError} = useReplayList({
     eventView,
@@ -129,11 +142,17 @@ const GroupReplaysTable = ({
   return (
     <StyledPageContent>
       <ReplayTable
+        fetchError={fetchError}
         isFetching={isFetching}
         replays={replays}
-        showProjectColumn={false}
         sort={first(eventView.sorts)}
-        fetchError={fetchError}
+        visibleColumns={[
+          ReplayColumns.session,
+          ...(hasRoomForColumns ? [ReplayColumns.startedAt] : []),
+          ReplayColumns.duration,
+          ReplayColumns.countErrors,
+          ReplayColumns.activity,
+        ]}
       />
       <Pagination pageLinks={pageLinks} />
     </StyledPageContent>

+ 16 - 2
static/app/views/performance/transactionSummary/transactionReplays/content.tsx

@@ -1,4 +1,5 @@
 import {browserHistory} from 'react-router';
+import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import {Location} from 'history';
 import first from 'lodash/first';
@@ -17,7 +18,9 @@ import space from 'sentry/styles/space';
 import type {Organization} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import EventView from 'sentry/utils/discover/eventView';
+import useMedia from 'sentry/utils/useMedia';
 import ReplayTable from 'sentry/views/replays/replayTable';
+import {ReplayColumns} from 'sentry/views/replays/replayTable/types';
 import type {ReplayListLocationQuery} from 'sentry/views/replays/types';
 
 import type {SpanOperationBreakdownFilter} from '../filter';
@@ -54,6 +57,9 @@ function ReplaysContent({
 }: Props) {
   const query = location.query;
 
+  const theme = useTheme();
+  const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.small})`);
+
   const eventsFilterOptions = getEventsFilterOptions(
     spanOperationBreakdownFilter,
     percentileValues
@@ -121,11 +127,19 @@ function ReplaysContent({
         />
       </FilterActions>
       <ReplayTable
+        fetchError={undefined}
         isFetching={isFetching}
         replays={replays}
-        showProjectColumn={false}
         sort={first(eventView.sorts) || {field: 'startedAt', kind: 'asc'}}
-        showSlowestTxColumn
+        visibleColumns={[
+          ReplayColumns.session,
+          ...(hasRoomForColumns
+            ? [ReplayColumns.slowestTransaction, ReplayColumns.startedAt]
+            : []),
+          ReplayColumns.duration,
+          ReplayColumns.countErrors,
+          ReplayColumns.activity,
+        ]}
       />
       <Pagination pageLinks={pageLinks} />
     </Layout.Main>

+ 0 - 383
static/app/views/replays/replayTable.tsx

@@ -1,383 +0,0 @@
-import {Fragment} from 'react';
-import {useTheme} from '@emotion/react';
-import styled from '@emotion/styled';
-
-import Alert from 'sentry/components/alert';
-import Duration from 'sentry/components/duration';
-import ProjectBadge from 'sentry/components/idBadge/projectBadge';
-import UserBadge from 'sentry/components/idBadge/userBadge';
-import Link from 'sentry/components/links/link';
-import {PanelTable} from 'sentry/components/panels';
-import QuestionTooltip from 'sentry/components/questionTooltip';
-import {StringWalker} from 'sentry/components/replays/walker/urlWalker';
-import ScoreBar from 'sentry/components/scoreBar';
-import TimeSince from 'sentry/components/timeSince';
-import CHART_PALETTE from 'sentry/constants/chartPalette';
-import {IconArrow, IconCalendar} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import space from 'sentry/styles/space';
-import type {Organization} from 'sentry/types';
-import EventView from 'sentry/utils/discover/eventView';
-import {spanOperationRelativeBreakdownRenderer} from 'sentry/utils/discover/fieldRenderers';
-import type {Sort} from 'sentry/utils/discover/fields';
-import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
-import {useLocation} from 'sentry/utils/useLocation';
-import useMedia from 'sentry/utils/useMedia';
-import useOrganization from 'sentry/utils/useOrganization';
-import useProjects from 'sentry/utils/useProjects';
-import {useRoutes} from 'sentry/utils/useRoutes';
-import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysFromTransaction';
-import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types';
-
-type Props = {
-  isFetching: boolean;
-  replays: undefined | ReplayListRecord[] | ReplayListRecordWithTx[];
-  showProjectColumn: boolean;
-  sort: Sort | undefined;
-  fetchError?: Error;
-  showSlowestTxColumn?: boolean;
-};
-
-type TableProps = {
-  showProjectColumn: boolean;
-  showSlowestTxColumn: boolean;
-};
-
-type RowProps = {
-  eventView: EventView;
-  minWidthIsSmall: boolean;
-  organization: Organization;
-  referrer: string;
-  replay: ReplayListRecord | ReplayListRecordWithTx;
-  showProjectColumn: boolean;
-  showSlowestTxColumn: boolean;
-};
-
-function SortableHeader({
-  fieldName,
-  label,
-  sort,
-  tooltip,
-}: {
-  fieldName: string;
-  label: string;
-  sort: Props['sort'];
-  tooltip?: string;
-}) {
-  const location = useLocation<ReplayListLocationQuery>();
-
-  const arrowDirection = sort?.kind === 'asc' ? 'up' : 'down';
-  const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
-
-  return (
-    <Header>
-      <SortLink
-        role="columnheader"
-        aria-sort={
-          sort?.field.endsWith(fieldName)
-            ? sort?.kind === 'asc'
-              ? 'ascending'
-              : 'descending'
-            : 'none'
-        }
-        to={{
-          pathname: location.pathname,
-          query: {
-            ...location.query,
-            sort: sort?.field.endsWith(fieldName)
-              ? sort?.kind === 'desc'
-                ? fieldName
-                : '-' + fieldName
-              : '-' + fieldName,
-          },
-        }}
-      >
-        {label} {sort?.field === fieldName && sortArrow}
-      </SortLink>
-      {tooltip ? (
-        <StyledQuestionTooltip size="xs" position="top" title={tooltip} />
-      ) : null}
-    </Header>
-  );
-}
-
-function ReplayTable({
-  isFetching,
-  replays,
-  showProjectColumn,
-  sort,
-  fetchError,
-  showSlowestTxColumn = false,
-}: Props) {
-  const routes = useRoutes();
-  const location = useLocation();
-  const referrer = getRouteStringFromRoutes(routes);
-
-  const organization = useOrganization();
-  const theme = useTheme();
-  const minWidthIsSmall = useMedia(`(min-width: ${theme.breakpoints.small})`);
-
-  const tableHeaders = [
-    t('Session'),
-    showProjectColumn && minWidthIsSmall && (
-      <SortableHeader
-        key="projectId"
-        sort={sort}
-        fieldName="projectId"
-        label={t('Project')}
-      />
-    ),
-    showSlowestTxColumn && minWidthIsSmall && (
-      <Header key="slowestTransaction">
-        {t('Slowest Transaction')}
-        <StyledQuestionTooltip
-          size="xs"
-          position="top"
-          title={t(
-            'Slowest single instance of this transaction captured by this session.'
-          )}
-        />
-      </Header>
-    ),
-    minWidthIsSmall && (
-      <SortableHeader
-        key="startedAt"
-        sort={sort}
-        fieldName="startedAt"
-        label={t('Start Time')}
-      />
-    ),
-    <SortableHeader
-      key="duration"
-      sort={sort}
-      fieldName="duration"
-      label={t('Duration')}
-    />,
-    <SortableHeader
-      key="countErrors"
-      sort={sort}
-      fieldName="countErrors"
-      label={t('Errors')}
-    />,
-    <SortableHeader
-      key="activity"
-      sort={sort}
-      fieldName="activity"
-      label={t('Activity')}
-      tooltip={t(
-        'Activity represents how much user activity happened in a replay. It is determined by the number of errors encountered, duration, and UI events.'
-      )}
-    />,
-  ].filter(Boolean);
-
-  if (fetchError && !isFetching) {
-    return (
-      <StyledPanelTable
-        headers={tableHeaders}
-        showProjectColumn={showProjectColumn}
-        isLoading={false}
-        showSlowestTxColumn={showSlowestTxColumn}
-      >
-        <StyledAlert type="error" showIcon>
-          {typeof fetchError === 'string'
-            ? fetchError
-            : t(
-                'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
-              )}
-        </StyledAlert>
-      </StyledPanelTable>
-    );
-  }
-
-  const eventView = EventView.fromLocation(location);
-
-  return (
-    <StyledPanelTable
-      isLoading={isFetching}
-      isEmpty={replays?.length === 0}
-      showProjectColumn={showProjectColumn}
-      showSlowestTxColumn={showSlowestTxColumn}
-      headers={tableHeaders}
-    >
-      {replays?.map(replay => (
-        <ReplayTableRow
-          eventView={eventView}
-          key={replay.id}
-          minWidthIsSmall={minWidthIsSmall}
-          organization={organization}
-          referrer={referrer}
-          replay={replay}
-          showProjectColumn={showProjectColumn}
-          showSlowestTxColumn={showSlowestTxColumn}
-        />
-      ))}
-    </StyledPanelTable>
-  );
-}
-
-function ReplayTableRow({
-  eventView,
-  minWidthIsSmall,
-  organization,
-  referrer,
-  replay,
-  showProjectColumn,
-  showSlowestTxColumn,
-}: RowProps) {
-  const location = useLocation();
-  const {projects} = useProjects();
-  const project = projects.find(p => p.id === replay.projectId);
-  const hasTxEvent = 'txEvent' in replay;
-  const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
-  const scoreBarPalette = new Array(10).fill([CHART_PALETTE[0][0]]);
-
-  return (
-    <Fragment>
-      <UserBadge
-        avatarSize={32}
-        displayName={
-          <Link
-            to={{
-              pathname: `/organizations/${organization.slug}/replays/${project?.slug}:${replay.id}/`,
-              query: {
-                referrer,
-                ...eventView.generateQueryStringObject(),
-              },
-            }}
-          >
-            {replay.user.displayName || ''}
-          </Link>
-        }
-        user={{
-          username: replay.user.displayName || '',
-          email: replay.user.email || '',
-          id: replay.user.id || '',
-          ip_address: replay.user.ip_address || '',
-          name: replay.user.name || '',
-        }}
-        // this is the subheading for the avatar, so displayEmail in this case is a misnomer
-        displayEmail={<StringWalker urls={replay.urls} />}
-      />
-      {showProjectColumn && minWidthIsSmall && (
-        <Item>{project ? <ProjectBadge project={project} avatarSize={16} /> : null}</Item>
-      )}
-      {minWidthIsSmall && showSlowestTxColumn && (
-        <Item>
-          {hasTxEvent ? (
-            <SpanOperationBreakdown>
-              {txDuration ? <TxDuration>{txDuration}ms</TxDuration> : null}
-              {spanOperationRelativeBreakdownRenderer(
-                replay.txEvent,
-                {
-                  organization,
-                  location,
-                },
-                {
-                  enableOnClick: false,
-                }
-              )}
-            </SpanOperationBreakdown>
-          ) : null}
-        </Item>
-      )}
-      {minWidthIsSmall && (
-        <Item>
-          <TimeSinceWrapper>
-            {minWidthIsSmall && <StyledIconCalendarWrapper color="gray500" size="sm" />}
-            <TimeSince date={replay.startedAt} />
-          </TimeSinceWrapper>
-        </Item>
-      )}
-      <Item>
-        <Duration seconds={replay.duration.asSeconds()} exact abbreviation />
-      </Item>
-      <Item data-test-id="replay-table-count-errors">{replay.countErrors || 0}</Item>
-      <Item>
-        <ScoreBar
-          size={20}
-          score={replay?.activity ?? 1}
-          palette={scoreBarPalette}
-          radius={0}
-        />
-      </Item>
-    </Fragment>
-  );
-}
-
-function getColCount(props: TableProps) {
-  let colCount = 4;
-  if (props.showSlowestTxColumn) {
-    colCount += 1;
-  }
-  if (props.showProjectColumn) {
-    colCount += 1;
-  }
-  return colCount;
-}
-
-const StyledPanelTable = styled(PanelTable)<TableProps>`
-  ${p => `grid-template-columns: minmax(0, 1fr) repeat(${getColCount(p)}, max-content);`}
-
-  @media (max-width: ${p => p.theme.breakpoints.small}) {
-    grid-template-columns: minmax(0, 1fr) repeat(3, min-content);
-  }
-`;
-
-const SortLink = styled(Link)`
-  color: inherit;
-
-  :hover {
-    color: inherit;
-  }
-
-  svg {
-    vertical-align: top;
-  }
-`;
-
-const Item = styled('div')`
-  display: flex;
-  align-items: center;
-`;
-
-const SpanOperationBreakdown = styled('div')`
-  width: 100%;
-  text-align: right;
-`;
-
-const TxDuration = styled('div')`
-  color: ${p => p.theme.gray500};
-  font-size: ${p => p.theme.fontSizeMedium};
-  margin-bottom: ${space(0.5)};
-`;
-
-const TimeSinceWrapper = styled('div')`
-  display: grid;
-  grid-template-columns: repeat(2, minmax(auto, max-content));
-  align-items: center;
-  gap: ${space(1)};
-`;
-
-const StyledIconCalendarWrapper = styled(IconCalendar)`
-  position: relative;
-  top: -1px;
-`;
-
-const StyledAlert = styled(Alert)`
-  border-radius: 0;
-  border-width: 1px 0 0 0;
-  grid-column: 1/-1;
-  margin-bottom: 0;
-`;
-
-const Header = styled('div')`
-  display: grid;
-  grid-template-columns: repeat(2, max-content);
-  align-items: center;
-`;
-
-const StyledQuestionTooltip = styled(QuestionTooltip)`
-  margin-left: ${space(0.5)};
-`;
-
-export default ReplayTable;

+ 55 - 0
static/app/views/replays/replayTable/headerCell.tsx

@@ -0,0 +1,55 @@
+import {t} from 'sentry/locale';
+import type {Sort} from 'sentry/utils/discover/fields';
+import SortableHeader from 'sentry/views/replays/replayTable/sortableHeader';
+import {ReplayColumns} from 'sentry/views/replays/replayTable/types';
+
+type Props = {
+  column: keyof typeof ReplayColumns;
+  sort?: Sort;
+};
+
+function HeaderCell({column, sort}: Props) {
+  switch (column) {
+    case 'session':
+      return <SortableHeader label={t('Session')} />;
+
+    case 'projectId':
+      return <SortableHeader sort={sort} fieldName="projectId" label={t('Project')} />;
+
+    case 'slowestTransaction':
+      return (
+        <SortableHeader
+          label={t('Slowest Transaction')}
+          tooltip={t(
+            'Slowest single instance of this transaction captured by this session.'
+          )}
+        />
+      );
+
+    case 'startedAt':
+      return <SortableHeader sort={sort} fieldName="startedAt" label={t('Start Time')} />;
+
+    case 'duration':
+      return <SortableHeader sort={sort} fieldName="duration" label={t('Duration')} />;
+
+    case 'countErrors':
+      return <SortableHeader sort={sort} fieldName="countErrors" label={t('Errors')} />;
+
+    case 'activity':
+      return (
+        <SortableHeader
+          sort={sort}
+          fieldName="activity"
+          label={t('Activity')}
+          tooltip={t(
+            'Activity represents how much user activity happened in a replay. It is determined by the number of errors encountered, duration, and UI events.'
+          )}
+        />
+      );
+
+    default:
+      return null;
+  }
+}
+
+export default HeaderCell;

+ 132 - 0
static/app/views/replays/replayTable/index.tsx

@@ -0,0 +1,132 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import Alert from 'sentry/components/alert';
+import {PanelTable} from 'sentry/components/panels';
+import {t} from 'sentry/locale';
+import EventView from 'sentry/utils/discover/eventView';
+import type {Sort} from 'sentry/utils/discover/fields';
+import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useRoutes} from 'sentry/utils/useRoutes';
+import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysFromTransaction';
+import HeaderCell from 'sentry/views/replays/replayTable/headerCell';
+import {
+  ActivityCell,
+  DurationCell,
+  ErrorCountCell,
+  ProjectCell,
+  SessionCell,
+  StartedAtCell,
+  TransactionCell,
+} from 'sentry/views/replays/replayTable/tableCell';
+import {ReplayColumns} from 'sentry/views/replays/replayTable/types';
+import type {ReplayListRecord} from 'sentry/views/replays/types';
+
+type Props = {
+  fetchError: undefined | Error;
+  isFetching: boolean;
+  replays: undefined | ReplayListRecord[] | ReplayListRecordWithTx[];
+  sort: Sort | undefined;
+  visibleColumns: Array<keyof typeof ReplayColumns>;
+};
+
+function ReplayTable({fetchError, isFetching, replays, sort, visibleColumns}: Props) {
+  const routes = useRoutes();
+  const location = useLocation();
+  const organization = useOrganization();
+
+  const tableHeaders = visibleColumns.map(column => (
+    <HeaderCell key={column} column={column} sort={sort} />
+  ));
+
+  if (fetchError && !isFetching) {
+    return (
+      <StyledPanelTable
+        headers={tableHeaders}
+        isLoading={false}
+        visibleColumns={visibleColumns}
+      >
+        <StyledAlert type="error" showIcon>
+          {typeof fetchError === 'string'
+            ? fetchError
+            : t(
+                'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
+              )}
+        </StyledAlert>
+      </StyledPanelTable>
+    );
+  }
+
+  const referrer = getRouteStringFromRoutes(routes);
+  const eventView = EventView.fromLocation(location);
+
+  return (
+    <StyledPanelTable
+      headers={tableHeaders}
+      isEmpty={replays?.length === 0}
+      isLoading={isFetching}
+      visibleColumns={visibleColumns}
+    >
+      {replays?.map(replay => {
+        return (
+          <Fragment key={replay.id}>
+            {visibleColumns.map(column => {
+              switch (column) {
+                case 'session':
+                  return (
+                    <SessionCell
+                      key="session"
+                      replay={replay}
+                      eventView={eventView}
+                      organization={organization}
+                      referrer={referrer}
+                    />
+                  );
+                case 'projectId':
+                  return <ProjectCell key="projectId" replay={replay} />;
+                case 'slowestTransaction':
+                  return (
+                    <TransactionCell
+                      key="slowestTransaction"
+                      replay={replay}
+                      organization={organization}
+                    />
+                  );
+                case 'startedAt':
+                  return <StartedAtCell key="startedAt" replay={replay} />;
+                case 'duration':
+                  return <DurationCell key="duration" replay={replay} />;
+                case 'countErrors':
+                  return <ErrorCountCell key="countErrors" replay={replay} />;
+                case 'activity':
+                  return <ActivityCell key="activity" replay={replay} />;
+                default:
+                  return null;
+              }
+            })}
+          </Fragment>
+        );
+      })}
+    </StyledPanelTable>
+  );
+}
+
+const StyledPanelTable = styled(PanelTable)<{
+  visibleColumns: Array<keyof typeof ReplayColumns>;
+}>`
+  grid-template-columns: ${p =>
+    p.visibleColumns
+      .map(column => (column === 'session' ? 'minmax(100px, 1fr)' : 'max-content'))
+      .join(' ')};
+`;
+
+const StyledAlert = styled(Alert)`
+  border-radius: 0;
+  border-width: 1px 0 0 0;
+  grid-column: 1/-1;
+  margin-bottom: 0;
+`;
+
+export default ReplayTable;

+ 99 - 0
static/app/views/replays/replayTable/sortableHeader.tsx

@@ -0,0 +1,99 @@
+import styled from '@emotion/styled';
+
+import Link from 'sentry/components/links/link';
+import QuestionTooltip from 'sentry/components/questionTooltip';
+import {IconArrow} from 'sentry/icons';
+import space from 'sentry/styles/space';
+import type {Sort} from 'sentry/utils/discover/fields';
+import {useLocation} from 'sentry/utils/useLocation';
+import type {ReplayListLocationQuery} from 'sentry/views/replays/types';
+
+type NotSortable = {
+  label: string;
+  tooltip?: string;
+};
+
+type Sortable = {
+  fieldName: string;
+  label: string;
+  sort: undefined | Sort;
+  tooltip?: string;
+};
+
+type Props = NotSortable | Sortable;
+
+function SortableHeader(props: Props) {
+  const location = useLocation<ReplayListLocationQuery>();
+
+  if (!('sort' in props) || !props.sort) {
+    const {label, tooltip} = props;
+    return (
+      <Header>
+        {label}
+        {tooltip ? (
+          <StyledQuestionTooltip size="xs" position="top" title={tooltip} />
+        ) : null}
+      </Header>
+    );
+  }
+
+  const {fieldName, label, sort, tooltip} = props;
+
+  const arrowDirection = sort?.kind === 'asc' ? 'up' : 'down';
+  const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
+
+  return (
+    <Header>
+      <SortLink
+        role="columnheader"
+        aria-sort={
+          sort?.field.endsWith(fieldName)
+            ? sort?.kind === 'asc'
+              ? 'ascending'
+              : 'descending'
+            : 'none'
+        }
+        to={{
+          pathname: location.pathname,
+          query: {
+            ...location.query,
+            sort: sort?.field.endsWith(fieldName)
+              ? sort?.kind === 'desc'
+                ? fieldName
+                : '-' + fieldName
+              : '-' + fieldName,
+          },
+        }}
+      >
+        {label} {sort?.field === fieldName && sortArrow}
+      </SortLink>
+      {tooltip ? (
+        <StyledQuestionTooltip size="xs" position="top" title={tooltip} />
+      ) : null}
+    </Header>
+  );
+}
+
+const Header = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(2, max-content);
+  align-items: center;
+`;
+
+const SortLink = styled(Link)`
+  color: inherit;
+
+  :hover {
+    color: inherit;
+  }
+
+  svg {
+    vertical-align: top;
+  }
+`;
+
+const StyledQuestionTooltip = styled(QuestionTooltip)`
+  margin-left: ${space(0.5)};
+`;
+
+export default SortableHeader;

+ 146 - 0
static/app/views/replays/replayTable/tableCell.tsx

@@ -0,0 +1,146 @@
+import styled from '@emotion/styled';
+
+import Duration from 'sentry/components/duration';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import UserBadge from 'sentry/components/idBadge/userBadge';
+import Link from 'sentry/components/links/link';
+import {StringWalker} from 'sentry/components/replays/walker/urlWalker';
+import ScoreBar from 'sentry/components/scoreBar';
+import TimeSince from 'sentry/components/timeSince';
+import CHART_PALETTE from 'sentry/constants/chartPalette';
+import {IconCalendar} from 'sentry/icons';
+import space from 'sentry/styles/space';
+import type {Organization} from 'sentry/types';
+import EventView from 'sentry/utils/discover/eventView';
+import {spanOperationRelativeBreakdownRenderer} from 'sentry/utils/discover/fieldRenderers';
+import {useLocation} from 'sentry/utils/useLocation';
+import useProjects from 'sentry/utils/useProjects';
+import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysFromTransaction';
+import type {ReplayListRecord} from 'sentry/views/replays/types';
+
+type Props = {
+  replay: ReplayListRecord | ReplayListRecordWithTx;
+};
+
+export function SessionCell({
+  eventView,
+  organization,
+  referrer,
+  replay,
+}: Props & {eventView: EventView; organization: Organization; referrer: string}) {
+  const {projects} = useProjects();
+  const project = projects.find(p => p.id === replay.projectId);
+
+  return (
+    <UserBadge
+      avatarSize={32}
+      displayName={
+        <Link
+          to={{
+            pathname: `/organizations/${organization.slug}/replays/${project?.slug}:${replay.id}/`,
+            query: {
+              referrer,
+              ...eventView.generateQueryStringObject(),
+            },
+          }}
+        >
+          {replay.user.displayName || ''}
+        </Link>
+      }
+      user={{
+        username: replay.user.displayName || '',
+        email: replay.user.email || '',
+        id: replay.user.id || '',
+        ip_address: replay.user.ip_address || '',
+        name: replay.user.name || '',
+      }}
+      // this is the subheading for the avatar, so displayEmail in this case is a misnomer
+      displayEmail={<StringWalker urls={replay.urls} />}
+    />
+  );
+}
+
+export function ProjectCell({replay}: Props) {
+  const {projects} = useProjects();
+  const project = projects.find(p => p.id === replay.projectId);
+
+  return (
+    <Item>{project ? <ProjectBadge project={project} avatarSize={16} /> : null}</Item>
+  );
+}
+
+export function TransactionCell({
+  organization,
+  replay,
+}: Props & {organization: Organization}) {
+  const location = useLocation();
+
+  const hasTxEvent = 'txEvent' in replay;
+  const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
+  return hasTxEvent ? (
+    <SpanOperationBreakdown>
+      {txDuration ? <div>{txDuration}ms</div> : null}
+      {spanOperationRelativeBreakdownRenderer(
+        replay.txEvent,
+        {
+          organization,
+          location,
+        },
+        {
+          enableOnClick: false,
+        }
+      )}
+    </SpanOperationBreakdown>
+  ) : null;
+}
+
+export function StartedAtCell({replay}: Props) {
+  return (
+    <Item>
+      <IconCalendar color="gray500" size="sm" />
+      <TimeSince date={replay.startedAt} />
+    </Item>
+  );
+}
+
+export function DurationCell({replay}: Props) {
+  return (
+    <Item>
+      <Duration seconds={replay.duration.asSeconds()} exact abbreviation />
+    </Item>
+  );
+}
+
+export function ErrorCountCell({replay}: Props) {
+  return <Item data-test-id="replay-table-count-errors">{replay.countErrors || 0}</Item>;
+}
+
+export function ActivityCell({replay}: Props) {
+  const scoreBarPalette = new Array(10).fill([CHART_PALETTE[0][0]]);
+  return (
+    <Item>
+      <ScoreBar
+        size={20}
+        score={replay?.activity ?? 1}
+        palette={scoreBarPalette}
+        radius={0}
+      />
+    </Item>
+  );
+}
+
+const Item = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(1)};
+`;
+
+const SpanOperationBreakdown = styled('div')`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: ${space(0.5)};
+  color: ${p => p.theme.gray500};
+  font-size: ${p => p.theme.fontSizeMedium};
+  text-align: right;
+`;

+ 9 - 0
static/app/views/replays/replayTable/types.tsx

@@ -0,0 +1,9 @@
+export enum ReplayColumns {
+  activity = 'activity',
+  countErrors = 'countErrors',
+  duration = 'duration',
+  projectId = 'projectId',
+  session = 'session',
+  slowestTransaction = 'slowestTransaction',
+  startedAt = 'startedAt',
+}

+ 12 - 3
static/app/views/replays/replays.tsx

@@ -22,6 +22,7 @@ import useOrganization from 'sentry/utils/useOrganization';
 import ReplaysFilters from 'sentry/views/replays/filters';
 import ReplayOnboardingPanel from 'sentry/views/replays/list/replayOnboardingPanel';
 import ReplayTable from 'sentry/views/replays/replayTable';
+import {ReplayColumns} from 'sentry/views/replays/replayTable/types';
 import type {ReplayListLocationQuery} from 'sentry/views/replays/types';
 
 type Props = RouteComponentProps<{orgId: string}, {}, any, ReplayListLocationQuery>;
@@ -29,7 +30,7 @@ type Props = RouteComponentProps<{orgId: string}, {}, any, ReplayListLocationQue
 function Replays({location}: Props) {
   const organization = useOrganization();
   const theme = useTheme();
-  const minWidthIsSmall = useMedia(`(min-width: ${theme.breakpoints.small})`);
+  const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.small})`);
 
   const eventView = useMemo(() => {
     const query = decodeScalar(location.query.query, '');
@@ -72,11 +73,19 @@ function Replays({location}: Props) {
           {hasSentOneReplay ? (
             <Fragment>
               <ReplayTable
-                isFetching={isFetching}
                 fetchError={fetchError}
+                isFetching={isFetching}
                 replays={replays}
-                showProjectColumn={minWidthIsSmall}
                 sort={eventView.sorts[0]}
+                visibleColumns={[
+                  ReplayColumns.session,
+                  ...(hasRoomForColumns
+                    ? [ReplayColumns.projectId, ReplayColumns.startedAt]
+                    : []),
+                  ReplayColumns.duration,
+                  ReplayColumns.countErrors,
+                  ReplayColumns.activity,
+                ]}
               />
               <Pagination
                 pageLinks={pageLinks}