Skip to content

Commit f7a5969

Browse files
ryan953cursoragent
authored andcommitted
feat(replay): Add a bulk-delete button (#95315)
Adds bulk-delete action to the replay list page, with support for deleting individually selected replays (from the page of 50 loaded onto the screen) or deleting any replay matching the current query. If you check `1` replay and click 'delete' it'll just go and do it. <img width="1020" height="317" alt="SCR-20250710-skfg" src="https://github.com/user-attachments/assets/5f2ec496-1d76-498b-836c-35bc087ffb71" /> But if you select `2` or more, then you'll get a confirmation dialog. You'll also get the confirmation dialog if you click the link Select all replays that match: ...` | Deleting Specific Replays (up to 50) | Deleting anything matching a query | | --- | --- | | <img width="635" height="399" alt="SCR-20250710-siuy" src="https://github.com/user-attachments/assets/faf7d772-9384-4ab2-8b38-66bbc4f2b0e9" /> | <img width="641" height="352" alt="SCR-20250710-sixc" src="https://github.com/user-attachments/assets/aab65c99-d2aa-4731-abfe-8bd300bbfb04" /> Depends on #95191 Fixes REPLAY-510
1 parent e82373e commit f7a5969

File tree

9 files changed

+418
-25
lines changed

9 files changed

+418
-25
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import {Fragment} from 'react';
2+
import styled from '@emotion/styled';
3+
import invariant from 'invariant';
4+
5+
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
6+
import {openConfirmModal} from 'sentry/components/confirm';
7+
import {UserAvatar} from 'sentry/components/core/avatar/userAvatar';
8+
import {Button} from 'sentry/components/core/button';
9+
import {Flex} from 'sentry/components/core/layout/flex';
10+
import {Link} from 'sentry/components/core/link';
11+
import {Tooltip} from 'sentry/components/core/tooltip';
12+
import Duration from 'sentry/components/duration/duration';
13+
import ErrorBoundary from 'sentry/components/errorBoundary';
14+
import {KeyValueData} from 'sentry/components/keyValueData';
15+
import {SimpleTable} from 'sentry/components/tables/simpleTable';
16+
import TimeSince from 'sentry/components/timeSince';
17+
import {IconCalendar, IconDelete} from 'sentry/icons';
18+
import {t, tct, tn} from 'sentry/locale';
19+
import {space} from 'sentry/styles/space';
20+
import type {Project} from 'sentry/types/project';
21+
import {getShortEventId} from 'sentry/utils/events';
22+
import type {QueryKeyEndpointOptions} from 'sentry/utils/queryClient';
23+
import {decodeList} from 'sentry/utils/queryString';
24+
import useDeleteReplays, {
25+
type ReplayBulkDeletePayload,
26+
} from 'sentry/utils/replays/hooks/useDeleteReplays';
27+
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
28+
import useProjectFromId from 'sentry/utils/useProjectFromId';
29+
import type {ReplayListRecord} from 'sentry/views/replays/types';
30+
31+
interface Props {
32+
queryOptions: QueryKeyEndpointOptions | undefined;
33+
replays: ReplayListRecord[];
34+
selectedIds: 'all' | string[];
35+
}
36+
37+
export default function DeleteReplays({selectedIds, replays, queryOptions}: Props) {
38+
const {project: projectIds} = useLocationQuery({
39+
fields: {
40+
project: decodeList,
41+
},
42+
});
43+
44+
const project = useProjectFromId({
45+
project_id: projectIds?.length === 1 ? projectIds[0] : undefined,
46+
});
47+
48+
const {bulkDelete, canDelete, queryOptionsToPayload} = useDeleteReplays({
49+
projectSlug: project?.slug ?? '',
50+
});
51+
const deletePayload = queryOptionsToPayload(selectedIds, queryOptions ?? {});
52+
53+
const settingsPath = `/settings/projects/${project?.slug}/replays/?replaySettingsTab=bulk-delete`;
54+
55+
return (
56+
<Tooltip
57+
disabled={canDelete}
58+
title={t('Select a single project from the dropdown to delete replays')}
59+
>
60+
<Button
61+
disabled={!canDelete}
62+
icon={<IconDelete />}
63+
onClick={() =>
64+
openConfirmModal({
65+
bypass: selectedIds !== 'all' && selectedIds.length === 1,
66+
renderMessage: _props => (
67+
<Fragment>
68+
{selectedIds === 'all' ? (
69+
<ReplayQueryPreview deletePayload={deletePayload} project={project!} />
70+
) : (
71+
<ErrorBoundary mini>
72+
<ReplayPreviewTable
73+
replays={replays}
74+
selectedIds={selectedIds}
75+
project={project!}
76+
/>
77+
</ErrorBoundary>
78+
)}
79+
</Fragment>
80+
),
81+
renderConfirmButton: ({defaultOnClick}) => (
82+
<Button onClick={defaultOnClick} priority="danger">
83+
{t('Delete')}
84+
</Button>
85+
),
86+
onConfirm: () => {
87+
bulkDelete([deletePayload], {
88+
onSuccess: () =>
89+
addSuccessMessage(
90+
tct('Replays are being deleted. [link:View progress]', {
91+
settings: <LinkWithUnderline to={settingsPath} />,
92+
})
93+
),
94+
onError: () =>
95+
addErrorMessage(
96+
tn(
97+
'Failed to delete replay',
98+
'Failed to delete replays',
99+
selectedIds === 'all' ? Number.MAX_SAFE_INTEGER : selectedIds.length
100+
)
101+
),
102+
onSettled: () => {},
103+
});
104+
},
105+
})
106+
}
107+
size="xs"
108+
>
109+
{t('Delete')}
110+
</Button>
111+
</Tooltip>
112+
);
113+
}
114+
115+
function ReplayQueryPreview({
116+
deletePayload,
117+
project,
118+
}: {
119+
deletePayload: ReplayBulkDeletePayload;
120+
project: Project;
121+
}) {
122+
const contentItems = Object.entries(deletePayload).map(([key, value]) => ({
123+
item: {
124+
key,
125+
subject: key,
126+
value,
127+
},
128+
}));
129+
return (
130+
<Fragment>
131+
<Title project={project}>
132+
{t('Replays matching the following query will be deleted')}
133+
</Title>
134+
<KeyValueData.Card contentItems={contentItems} />
135+
</Fragment>
136+
);
137+
}
138+
139+
function ReplayPreviewTable({
140+
project,
141+
replays,
142+
selectedIds,
143+
}: {
144+
project: Project;
145+
replays: ReplayListRecord[];
146+
selectedIds: string[];
147+
}) {
148+
return (
149+
<Fragment>
150+
<Title project={project}>
151+
{tn(
152+
'The following %s replay will be deleted',
153+
'The following %s replays will be deleted',
154+
selectedIds.length
155+
)}
156+
</Title>
157+
<SimpleTableWithTwoColumns>
158+
<SimpleTable.Header>
159+
<SimpleTable.HeaderCell>{t('Replay')}</SimpleTable.HeaderCell>
160+
<SimpleTable.HeaderCell>{t('Duration')}</SimpleTable.HeaderCell>
161+
</SimpleTable.Header>
162+
{selectedIds.map(id => {
163+
const replay = replays.find(r => r.id === id) as ReplayListRecord;
164+
if (replay.is_archived) {
165+
return null;
166+
}
167+
invariant(
168+
replay.duration && replay.started_at,
169+
'For TypeScript: replay.duration and replay.started_at are implied because replay.is_archived is false'
170+
);
171+
172+
return (
173+
<SimpleTable.Row key={id}>
174+
<SimpleTable.RowCell>
175+
<Flex key="session" align="center" gap={space(1)}>
176+
<UserAvatar
177+
user={{
178+
username: replay.user?.display_name || '',
179+
email: replay.user?.email || '',
180+
id: replay.user?.id || '',
181+
ip_address: replay.user?.ip || '',
182+
name: replay.user?.username || '',
183+
}}
184+
size={24}
185+
/>
186+
<SubText>
187+
<Flex gap={space(0.5)} align="flex-start">
188+
<DisplayName>
189+
{replay.user.display_name || t('Anonymous User')}
190+
</DisplayName>
191+
</Flex>
192+
<Flex gap={space(0.5)}>
193+
{getShortEventId(replay.id)}
194+
<Flex gap={space(0.5)}>
195+
<IconCalendar color="gray300" size="xs" />
196+
<TimeSince date={replay.started_at} />
197+
</Flex>
198+
</Flex>
199+
</SubText>
200+
</Flex>
201+
</SimpleTable.RowCell>
202+
<SimpleTable.RowCell justify="flex-end">
203+
<Duration
204+
duration={[replay.duration.asMilliseconds() ?? 0, 'ms']}
205+
precision="sec"
206+
/>
207+
</SimpleTable.RowCell>
208+
</SimpleTable.Row>
209+
);
210+
})}
211+
</SimpleTableWithTwoColumns>
212+
</Fragment>
213+
);
214+
}
215+
216+
function Title({children, project}: {children: React.ReactNode; project: Project}) {
217+
const settingsPath = `/settings/projects/${project.slug}/replays/?replaySettingsTab=bulk-delete`;
218+
return (
219+
<Fragment>
220+
<p>
221+
<strong>{children}</strong>
222+
</p>
223+
<p>
224+
{tct('You can track the progress in [link].', {
225+
link: (
226+
<LinkWithUnderline
227+
to={settingsPath}
228+
>{`Settings > ${project.slug} > Replays`}</LinkWithUnderline>
229+
),
230+
})}
231+
</p>
232+
</Fragment>
233+
);
234+
}
235+
236+
const SimpleTableWithTwoColumns = styled(SimpleTable)`
237+
grid-template-columns: 1fr max-content;
238+
`;
239+
240+
const SubText = styled('div')`
241+
font-size: 0.875em;
242+
line-height: normal;
243+
color: ${p => p.theme.subText};
244+
${p => p.theme.overflowEllipsis};
245+
display: flex;
246+
flex-direction: column;
247+
gap: ${space(0.25)};
248+
align-items: flex-start;
249+
`;
250+
251+
const DisplayName = styled('span')`
252+
color: ${p => p.theme.textColor};
253+
font-size: ${p => p.theme.fontSize.md};
254+
font-weight: ${p => p.theme.fontWeight.bold};
255+
line-height: normal;
256+
${p => p.theme.overflowEllipsis};
257+
`;
258+
259+
const LinkWithUnderline = styled(Link)`
260+
cursor: pointer;
261+
&:hover {
262+
text-decoration: underline;
263+
}
264+
`;

static/app/components/replays/table/replayTable.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ export default function ReplayTable({
4646
data-test-id="replay-table-loading"
4747
style={{gridTemplateColumns}}
4848
>
49-
<ReplayTableHeader columns={columns} onSortClick={onSortClick} sort={sort} />
49+
<ReplayTableHeader
50+
columns={columns}
51+
replays={replays}
52+
onSortClick={onSortClick}
53+
sort={sort}
54+
/>
5055
<SimpleTable.Empty>
5156
<LoadingIndicator />
5257
</SimpleTable.Empty>
@@ -60,7 +65,13 @@ export default function ReplayTable({
6065
data-test-id="replay-table-errored"
6166
style={{gridTemplateColumns}}
6267
>
63-
<ReplayTableHeader columns={columns} onSortClick={onSortClick} sort={sort} />
68+
<ReplayTableHeader
69+
columns={columns}
70+
onSortClick={onSortClick}
71+
replays={replays}
72+
sort={sort}
73+
/>
74+
6475
<SimpleTable.Empty>
6576
<Alert type="error" showIcon>
6677
{t('Sorry, the list of replays could not be loaded. ')}
@@ -73,7 +84,12 @@ export default function ReplayTable({
7384

7485
return (
7586
<StyledSimpleTable data-test-id="replay-table" style={{gridTemplateColumns}}>
76-
<ReplayTableHeader columns={columns} onSortClick={onSortClick} sort={sort} />
87+
<ReplayTableHeader
88+
columns={columns}
89+
onSortClick={onSortClick}
90+
replays={replays}
91+
sort={sort}
92+
/>
7793
{replays.length === 0 && (
7894
<SimpleTable.Empty>{t('No replays found')}</SimpleTable.Empty>
7995
)}

static/app/components/replays/table/replayTableColumns.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type ListRecord = ReplayListRecord | ReplayListRecordWithTx;
5353
interface HeaderProps {
5454
columnIndex: number;
5555
listItemCheckboxState: ReturnType<typeof useListItemCheckboxContext>;
56+
replays: ReplayListRecord[];
5657
}
5758

5859
interface CellProps {
@@ -368,6 +369,7 @@ export const ReplayPlayPauseColumn: ReplayTableColumn = {
368369
export const ReplaySelectColumn: ReplayTableColumn = {
369370
Header: ({
370371
listItemCheckboxState: {isAllSelected, deselectAll, knownIds, toggleSelected},
372+
replays,
371373
}) => {
372374
const organization = useOrganization();
373375
if (!organization.features.includes('replay-list-select')) {
@@ -383,7 +385,10 @@ export const ReplaySelectColumn: ReplayTableColumn = {
383385
if (isAllSelected === true) {
384386
deselectAll();
385387
} else {
386-
toggleSelected(knownIds);
388+
// If the replay is archived, don't include it in the selection
389+
toggleSelected(
390+
knownIds.filter(id => !replays.find(r => r.id === id)?.is_archived)
391+
);
387392
}
388393
}}
389394
/>

static/app/components/replays/table/replayTableHeader.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@ import ReplayTableHeaderActions from 'sentry/components/replays/table/replayTabl
33
import {SimpleTable} from 'sentry/components/tables/simpleTable';
44
import type {Sort} from 'sentry/utils/discover/fields';
55
import {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
6+
import type {ReplayListRecord} from 'sentry/views/replays/types';
67

78
type Props = {
89
columns: readonly ReplayTableColumn[];
10+
replays: ReplayListRecord[];
911
onSortClick?: (key: string) => void;
1012
sort?: Sort;
1113
};
1214

13-
export default function ReplayTableHeader({columns, onSortClick, sort}: Props) {
15+
export default function ReplayTableHeader({columns, replays, onSortClick, sort}: Props) {
1416
const listItemCheckboxState = useListItemCheckboxContext();
1517

1618
if (listItemCheckboxState.isAnySelected) {
17-
return <ReplayTableHeaderActions listItemCheckboxState={listItemCheckboxState} />;
19+
return (
20+
<ReplayTableHeaderActions
21+
listItemCheckboxState={listItemCheckboxState}
22+
replays={replays}
23+
/>
24+
);
1825
}
1926

2027
return (
@@ -26,7 +33,7 @@ export default function ReplayTableHeader({columns, onSortClick, sort}: Props) {
2633
sort={column.sortKey && sort?.field === column.sortKey ? sort.kind : undefined}
2734
>
2835
{typeof column.Header === 'function'
29-
? column.Header({columnIndex, listItemCheckboxState})
36+
? column.Header({columnIndex, listItemCheckboxState, replays})
3037
: column.Header}
3138
</SimpleTable.HeaderCell>
3239
))}

0 commit comments

Comments
 (0)