Skip to content

ref(replay): Convert ReplayTable to use SimpleTable under the hood #94590

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions static/app/components/events/eventReplay/replayPreviewPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {ReplayFullscreenButton} from 'sentry/components/replays/replayFullscreen
import ReplayPlayer from 'sentry/components/replays/replayPlayer';
import ReplayPlayPauseButton from 'sentry/components/replays/replayPlayPauseButton';
import {ReplaySidebarToggleButton} from 'sentry/components/replays/replaySidebarToggleButton';
import {ReplaySessionColumn} from 'sentry/components/replays/table/replayTableColumns';
import TimeAndScrubberGrid from 'sentry/components/replays/timeAndScrubberGrid';
import {IconNext, IconPrevious} from 'sentry/icons';
import {t} from 'sentry/locale';
Expand All @@ -29,7 +30,6 @@ import Breadcrumbs from 'sentry/views/replays/detail/breadcrumbs';
import BrowserOSIcons from 'sentry/views/replays/detail/browserOSIcons';
import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
import {makeReplaysPathname} from 'sentry/views/replays/pathnames';
import {ReplayCell} from 'sentry/views/replays/replayTable/tableCell';
import type {ReplayListRecord, ReplayRecord} from 'sentry/views/replays/types';

export default function ReplayPreviewPlayer({
Expand Down Expand Up @@ -93,10 +93,11 @@ export default function ReplayPreviewPlayer({
</StyledAlert>
)}
<HeaderWrapper>
<StyledReplayCell
key="session"
<ReplaySessionColumn.Component
replay={replayRecord as ReplayListRecord}
rowIndex={0}
columnIndex={0}
showDropdownFilters={false}
/>
<LinkButton
size="sm"
Expand Down Expand Up @@ -242,10 +243,6 @@ const ContextContainer = styled('div')`
gap: ${space(1)};
`;

const StyledReplayCell = styled(ReplayCell)`
padding: 0 0 ${space(1)};
`;

const HeaderWrapper = styled('div')`
display: flex;
justify-content: space-between;
Expand Down
199 changes: 199 additions & 0 deletions static/app/components/replays/table/replayTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import type {HTMLAttributes, ReactNode} from 'react';
import styled from '@emotion/styled';

import {Alert} from 'sentry/components/core/alert';
import InteractionStateLayer from 'sentry/components/core/interactionStateLayer';
import {Tooltip} from 'sentry/components/core/tooltip';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import type {ReplayTableColumn} from 'sentry/components/replays/table/replayTableColumns';
import {ReplaySessionColumn} from 'sentry/components/replays/table/replayTableColumns';
import {SimpleTable} from 'sentry/components/tables/simpleTable';
import {t} from 'sentry/locale';
import type {Sort} from 'sentry/utils/discover/fields';
import type RequestError from 'sentry/utils/requestError/requestError';
import {ERROR_MAP} from 'sentry/utils/requestError/requestError';
import type {ReplayListRecord} from 'sentry/views/replays/types';

type SortProps =
| {
onSortClick: (key: string) => void;
sort: Sort;
}
| {onSortClick?: never; sort?: never};

type Props = SortProps & {
columns: readonly ReplayTableColumn[];
error: RequestError | null | undefined;
isPending: boolean;
replays: ReplayListRecord[];
showDropdownFilters: boolean;
onClickRow?: (props: {replay: ReplayListRecord; rowIndex: number}) => void;
};

export default function ReplayTable({
columns,
error,
isPending,
onClickRow,
onSortClick,
replays,
showDropdownFilters,
sort,
}: Props) {
if (isPending) {
return (
<ReplayTableWithColumns
data-test-id="replay-table-loading"
columns={columns}
sort={sort}
onSortClick={onSortClick}
>
<SimpleTable.Empty>
<LoadingIndicator />
</SimpleTable.Empty>
</ReplayTableWithColumns>
);
}

if (error) {
return (
<ReplayTableWithColumns
data-test-id="replay-table-errored"
columns={columns}
sort={sort}
onSortClick={onSortClick}
>
<SimpleTable.Empty>
<Alert type="error" showIcon>
{t('Sorry, the list of replays could not be loaded. ')}
{getErrorMessage(error)}
</Alert>
</SimpleTable.Empty>
</ReplayTableWithColumns>
);
}

return (
<ReplayTableWithColumns
data-test-id="replay-table"
columns={columns}
sort={sort}
onSortClick={onSortClick}
>
{replays.length === 0 && (
<SimpleTable.Empty>{t('No replays found')}</SimpleTable.Empty>
)}
{replays.map((replay, rowIndex) => {
const rows = columns.map((column, columnIndex) => (
<RowCell key={`${replay.id}-${column.sortKey}`}>
<column.Component
columnIndex={columnIndex}
replay={replay}
rowIndex={rowIndex}
showDropdownFilters={showDropdownFilters}
/>
</RowCell>
));
return (
<SimpleTable.Row
key={replay.id}
variant={replay.is_archived ? 'faded' : 'default'}
>
{onClickRow ? (
<RowContentButton as="div" onClick={() => onClickRow({replay, rowIndex})}>
<InteractionStateLayer />
{rows}
</RowContentButton>
) : (
rows
)}
</SimpleTable.Row>
);
})}
</ReplayTableWithColumns>
);
}

type TableProps = {
children: ReactNode;
columns: readonly ReplayTableColumn[];
onSortClick?: (key: string) => void;
sort?: Sort;
} & HTMLAttributes<HTMLTableElement>;

const ReplayTableWithColumns = styled(
({children, columns, onSortClick, sort, ...props}: TableProps) => (
<SimpleTable {...props}>
<SimpleTable.Header>
{columns.map((column, columnIndex) => (
<SimpleTable.HeaderCell
key={`${column.name}-${columnIndex}`}
handleSortClick={() => column.sortKey && onSortClick?.(column.sortKey)}
sort={
column.sortKey && sort?.field === column.sortKey ? sort.kind : undefined
}
>
<Tooltip title={column.tooltip} disabled={!column.tooltip}>
{column.name}
</Tooltip>
</SimpleTable.HeaderCell>
))}
</SimpleTable.Header>

{children}
</SimpleTable>
)
)`
${p => getGridTemplateColumns(p.columns)}
margin-bottom: 0;
overflow: auto;

[data-clickable='true'] {
cursor: pointer;
}
`;

function getGridTemplateColumns(columns: readonly ReplayTableColumn[]) {
return `grid-template-columns: ${columns
.map(column =>
column === ReplaySessionColumn ? 'minmax(150px, 1fr)' : 'max-content'
)
.join(' ')};`;
}

function getErrorMessage(fetchError: RequestError) {
if (typeof fetchError === 'string') {
return fetchError;
}
if (typeof fetchError?.responseJSON?.detail === 'string') {
return fetchError.responseJSON.detail;
}
if (fetchError?.responseJSON?.detail?.message) {
return fetchError.responseJSON.detail.message;
}
if (fetchError.name === ERROR_MAP[500]) {
return t('There was an internal systems error.');
}
return t(
'This could be due to invalid search parameters or an internal systems error.'
);
}

const RowContentButton = styled('button')`
display: contents;
cursor: pointer;

border: none;
background: transparent;
margin: 0;
padding: 0;
`;

const RowCell = styled(SimpleTable.RowCell)`
position: relative;
overflow: auto;

&:hover [data-visible-on-hover='true'] {
opacity: 1;
}
`;
13 changes: 8 additions & 5 deletions static/app/components/replays/table/replayTableColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ interface RenderProps {
showDropdownFilters: boolean;
}

interface ReplayTableColumn {
export interface ReplayTableColumn {
Component: (props: RenderProps) => ReactNode;
name: string;
sortKey: undefined | ReplayRecordNestedFieldName;
Expand Down Expand Up @@ -183,7 +183,10 @@ export const ReplayCountErrorsColumn: ReplayTableColumn = {
return null;
}
return (
<DropdownContainer key="countErrors">
<DropdownContainer
key="countErrors"
data-test-id="replay-table-column-count-errors"
>
<TabularNumber>
{replay.count_errors ? (
<Flex gap={space(0.5)}>
Expand Down Expand Up @@ -401,7 +404,7 @@ export const ReplaySessionColumn: ReplayTableColumn = {
<Flex key="session" align="center" gap={space(1)}>
<UserAvatar user={getUserBadgeUser(replay)} size={24} />
<SubText>
<Flex gap={space(0.5)}>
<Flex gap={space(0.5)} align="flex-start">
<DisplayNameLink
to={
isIssuesReplayList
Expand Down Expand Up @@ -484,7 +487,6 @@ function getUserBadgeUser(replay: ListRecord) {
}

const DropdownContainer = styled(Flex)`
position: relative;
flex-direction: column;
justify-content: center;
`;
Expand All @@ -505,6 +507,7 @@ const SubText = styled('div')`
display: flex;
flex-direction: column;
gap: ${space(0.25)};
align-items: flex-start;
`;

const DisplayNameLink = styled(Link)`
Expand All @@ -523,7 +526,7 @@ const PlayPauseButtonContainer = styled(Flex)`
flex-direction: column;
justify-content: center;

margin-inline: -${space(1)};
margin: 0 -${space(2)} 0 -${space(1)};
`;

const SpanOperationBreakdown = styled('div')`
Expand Down
51 changes: 51 additions & 0 deletions static/app/components/replays/table/useReplayTableSort.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {useCallback, useRef} from 'react';

import {trackAnalytics} from 'sentry/utils/analytics';
import {encodeSort} from 'sentry/utils/discover/eventView';
import type {Sort} from 'sentry/utils/discover/fields';
import {decodeSorts} from 'sentry/utils/queryString';
import useOrganization from 'sentry/utils/useOrganization';
import useUrlParams from 'sentry/utils/useUrlParams';

interface Props {
defaultSort?: Sort;
queryParamKey?: string;
}

const DEFAULT_SORT = {field: 'started_at', kind: 'asc'} as const;

export default function useReplayTableSort({
defaultSort = DEFAULT_SORT,
queryParamKey = 'sort',
}: Props = {}) {
const defaultSortRef = useRef(defaultSort);
const organization = useOrganization();

const {getParamValue, setParamValue} = useUrlParams(queryParamKey, '-started_at');
const sortQuery = getParamValue();
const sortType = decodeSorts(sortQuery).at(0) ?? defaultSortRef.current;

const handleSortClick = useCallback(
(key: string) => {
const newSort = {
field: key,
kind:
key === sortType.field ? (sortType.kind === 'asc' ? 'desc' : 'asc') : 'desc',
} satisfies Sort;

setParamValue(encodeSort(newSort));

trackAnalytics('replay.list-sorted', {
organization,
column: key,
});
},
[organization, setParamValue, sortType]
);

return {
sortType,
sortQuery,
onSortClick: handleSortClick,
};
}
19 changes: 0 additions & 19 deletions static/app/utils/replays/hooks/useFetchReplayList.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,7 @@ describe('GroupReplays', () => {
deprecatedRouterMocks: true,
});

expect(
await screen.findByText('There are no items to display')
).toBeInTheDocument();
expect(await screen.findByText('No replays found')).toBeInTheDocument();
expect(mockReplayCountApi).toHaveBeenCalled();
expect(mockReplayApi).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -447,14 +445,14 @@ describe('GroupReplays', () => {
expect(screen.getByText('06:40')).toBeInTheDocument();

// Expect the first row to have the correct errors
expect(screen.getAllByTestId('replay-table-count-errors')[0]).toHaveTextContent(
'1'
);
expect(
screen.getAllByTestId('replay-table-column-count-errors')[0]
).toHaveTextContent('1');

// Expect the second row to have the correct errors
expect(screen.getAllByTestId('replay-table-count-errors')[1]).toHaveTextContent(
'4'
);
expect(
screen.getAllByTestId('replay-table-column-count-errors')[1]
).toHaveTextContent('4');

// Expect the first row to have the correct date
expect(screen.getByText('14 days ago')).toBeInTheDocument();
Expand Down
Loading
Loading