Skip to content
Open
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
163 changes: 63 additions & 100 deletions static/app/components/replays/table/deleteReplays.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Fragment, useCallback} from 'react';
import {Fragment} from 'react';
import styled from '@emotion/styled';
import invariant from 'invariant';

Expand All @@ -10,7 +10,6 @@ import {Button} from 'sentry/components/core/button';
import {Flex} from 'sentry/components/core/layout/flex';
import {Link} from 'sentry/components/core/link';
import {Text} from 'sentry/components/core/text';
import {Tooltip} from 'sentry/components/core/tooltip';
import Duration from 'sentry/components/duration/duration';
import ErrorBoundary from 'sentry/components/errorBoundary';
import {KeyValueData} from 'sentry/components/keyValueData';
Expand All @@ -23,51 +22,31 @@ import {space} from 'sentry/styles/space';
import type {Project} from 'sentry/types/project';
import {getShortEventId} from 'sentry/utils/events';
import {useQueryClient, type QueryKeyEndpointOptions} from 'sentry/utils/queryClient';
import {decodeList} from 'sentry/utils/queryString';
import useDeleteReplays, {
type ReplayBulkDeletePayload,
} from 'sentry/utils/replays/hooks/useDeleteReplays';
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
import useProjectFromId from 'sentry/utils/useProjectFromId';
import useProjects from 'sentry/utils/useProjects';
import type {ReplayListRecord} from 'sentry/views/replays/types';

interface Props {
disabled: boolean;
project: Project;
queryOptions:
| QueryKeyEndpointOptions<unknown, Record<string, string>, unknown>
| undefined;
replays: ReplayListRecord[];
selectedIds: 'all' | string[];
}

export default function DeleteReplays({selectedIds, replays, queryOptions}: Props) {
export default function DeleteReplays({
disabled,
project,
queryOptions,
replays,
selectedIds,
}: Props) {
const queryClient = useQueryClient();
const analyticsArea = useAnalyticsArea();
const {project: selectedProjectIds} = useLocationQuery({
fields: {
project: decodeList,
},
});
const {projects} = useProjects();
const hasOnlyOneProject = projects.length === 1;

// if 1 project is selected, use it
// if no project is selected but only 1 project exists, use that
const project = useProjectFromId({
project_id:
selectedProjectIds.length === 1
? selectedProjectIds[0]
: hasOnlyOneProject
? projects[0]?.id
: undefined,
});
const hasOneProjectSelected = Boolean(project);

const oneProjectEligible = hasOneProjectSelected || hasOnlyOneProject;

const {bulkDelete, hasAccess, queryOptionsToPayload} = useDeleteReplays({
projectSlug: project?.slug ?? '',
});
const {bulkDelete, queryOptionsToPayload} = useDeleteReplays({project});
const deletePayload = queryOptionsToPayload(selectedIds, queryOptions ?? {});

const settingsPath = `/settings/projects/${project?.slug}/replays/?replaySettingsTab=bulk-delete`;
Expand All @@ -76,77 +55,61 @@ export default function DeleteReplays({selectedIds, replays, queryOptions}: Prop
projectSlug: project?.slug ?? '',
query: {referrer: analyticsArea},
});
const refetchAuditLog = useCallback(() => {
queryClient.invalidateQueries({queryKey});
}, [queryClient, queryKey]);

return (
<Tooltip
disabled={oneProjectEligible}
title={t('Select a single project from the dropdown to delete replays')}
>
<Tooltip
disabled={!oneProjectEligible || hasAccess}
title={t('You must have project:write or project:admin access to delete replays')}
>
<Button
disabled={!oneProjectEligible || !hasAccess}
icon={<IconDelete />}
onClick={() =>
openConfirmModal({
bypass: selectedIds !== 'all' && selectedIds.length === 1,
renderMessage: _props =>
selectedIds === 'all' ? (
<ReplayQueryPreview deletePayload={deletePayload} project={project!} />
) : (
<ErrorBoundary mini>
<Title project={project!}>
{tn(
'The following %s replay will be deleted',
'The following %s replays will be deleted',
selectedIds.length
)}
</Title>
<ReplayPreviewTable replays={replays} selectedIds={selectedIds} />
</ErrorBoundary>
),
renderConfirmButton: ({defaultOnClick}) => (
<Button onClick={defaultOnClick} priority="danger">
{t('Delete')}
</Button>
),
onConfirm: () => {
bulkDelete([deletePayload], {
onSuccess: () => {
addSuccessMessage(
tct('Replays are being deleted. [settings:View progress]', {
settings: <LinkWithUnderline to={settingsPath} />,
})
);
// TODO: get the list to refetch
refetchAuditLog();
},
onError: () =>
addErrorMessage(
tn(
'Failed to delete replay',
'Failed to delete replays',
selectedIds === 'all'
? Number.MAX_SAFE_INTEGER
: selectedIds.length
)
),
onSettled: () => {},
});
<Button
disabled={disabled}
icon={<IconDelete />}
onClick={() =>
openConfirmModal({
bypass: selectedIds !== 'all' && selectedIds.length === 1,
renderMessage: _props =>
selectedIds === 'all' ? (
<ReplayQueryPreview deletePayload={deletePayload} project={project} />
) : (
<ErrorBoundary mini>
<Title project={project}>
{tn(
'The following %s replay will be deleted',
'The following %s replays will be deleted',
selectedIds.length
)}
</Title>
<ReplayPreviewTable replays={replays} selectedIds={selectedIds} />
</ErrorBoundary>
),
renderConfirmButton: ({defaultOnClick}) => (
<Button onClick={defaultOnClick} priority="danger">
{t('Delete')}
</Button>
),
onConfirm: () => {
bulkDelete([deletePayload], {
onSuccess: () => {
addSuccessMessage(
tct('Replays are being deleted. [settings:View progress]', {
settings: <LinkWithUnderline to={settingsPath} />,
})
);
queryClient.invalidateQueries({queryKey});
},
})
}
size="xs"
>
{t('Delete')}
</Button>
</Tooltip>
</Tooltip>
onError: () =>
addErrorMessage(
tn(
'Failed to delete replay',
'Failed to delete replays',
selectedIds === 'all' ? Number.MAX_SAFE_INTEGER : selectedIds.length
)
),
onSettled: () => {},
});
},
})
}
size="xs"
>
{t('Delete')}
</Button>
);
}

Expand Down
84 changes: 63 additions & 21 deletions static/app/components/replays/table/replayTableHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {Fragment} from 'react';
import styled from '@emotion/styled';

import {Tooltip} from '@sentry/scraps/tooltip/tooltip';

import {Alert} from 'sentry/components/core/alert';
import {Flex} from 'sentry/components/core/layout/flex';
import DeleteReplays from 'sentry/components/replays/table/deleteReplays';
Expand All @@ -13,6 +15,11 @@ import {t, tct, tn} from 'sentry/locale';
import type {Sort} from 'sentry/utils/discover/fields';
import {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
import {parseQueryKey} from 'sentry/utils/queryClient';
import {decodeList} from 'sentry/utils/queryString';
import useDeleteReplayHasAccess from 'sentry/utils/replays/hooks/useDeleteReplayHasAccess';
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
import useProjectFromId from 'sentry/utils/useProjectFromId';
import useProjects from 'sentry/utils/useProjects';
import type {ReplayListRecord} from 'sentry/views/replays/types';

type Props = {
Expand All @@ -29,6 +36,35 @@ export default function ReplayTableHeader({columns, replays, onSortClick, sort}:
const queryOptions = parseQueryKey(queryKey).options;
const queryString = queryOptions?.query?.query;

const {project: selectedProjectIds} = useLocationQuery({
fields: {
project: decodeList,
},
});
const {projects} = useProjects();
const hasOnlyOneProject = projects.length === 1;

// if 1 project is selected, use it
// if no project is selected but only 1 project exists, use that
const project = useProjectFromId({
project_id:
selectedProjectIds.length === 1
? selectedProjectIds[0]
: hasOnlyOneProject
? projects[0]?.id
: undefined,
});

const hasAccess = useDeleteReplayHasAccess({project});
const hasOneProjectSelected = Boolean(project);
const oneProjectEligible = hasOneProjectSelected || hasOnlyOneProject;

const disabledMessage = oneProjectEligible
? hasAccess
? undefined
: t('You must have project:write or project:admin access to delete replays')
: t('Select a single project from the dropdown to delete replays');

return (
<Fragment>
<TableHeader>
Expand Down Expand Up @@ -57,18 +93,24 @@ export default function ReplayTableHeader({columns, replays, onSortClick, sort}:
/>
</TableCellFirst>
<TableCellsRemaining>
<DeleteReplays
queryOptions={queryOptions}
replays={replays}
selectedIds={selectedIds}
/>
<Tooltip disabled={!disabledMessage} title={disabledMessage}>
{project ? (
Copy link
Member

@srest2021 srest2021 Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabled delete button now disappears when banner is "Select a single project from the dropdown to delete replays", but not when banner is "You must have project:write or project:admin access to delete replays" -- is this intentional?

<DeleteReplays
disabled={!oneProjectEligible || !hasAccess}
project={project}
queryOptions={queryOptions}
replays={replays}
selectedIds={selectedIds}
/>
) : null}
</Tooltip>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Button and Tooltip Vanish on Null Project

The DeleteReplays button and its tooltip disappear when project is null, even if oneProjectEligible is true and a disabled state with an explanation is intended. This happens because the button is conditionally rendered based on project's existence, which also causes the Tooltip to receive null children.

Fix in Cursor Fix in Web

</TableCellsRemaining>
</TableHeader>
) : null}

{isAllSelected === 'indeterminate' ? (
<FullGridAlert type="warning" system>
<Flex justify="center" wrap="wrap" gap="md">
<Flex justify="center" wrap="wrap" gap="sm">
{tn(
'Selected %s visible replay.',
'Selected %s visible replays.',
Expand All @@ -87,21 +129,19 @@ export default function ReplayTableHeader({columns, replays, onSortClick, sort}:

{isAllSelected === true ? (
<FullGridAlert type="warning" system>
<Flex justify="center" wrap="wrap">
<span>
{queryString
? tct('Selected all replays matching: [queryString].', {
queryString: <var>{queryString}</var>,
})
: countSelected > replays.length
? t('Selected all %s+ replays.', replays.length)
: tn(
'Selected all %s replay.',
'Selected all %s replays.',
countSelected
)}
</span>
</Flex>
{queryString
? tct('Selected all replays matching: [queryString].', {
queryString: <var>{queryString}</var>,
})
: countSelected > replays.length
? t('Selected all %s+ replays.', replays.length)
: tn('Selected all %s replay.', 'Selected all %s replays.', countSelected)}
</FullGridAlert>
) : null}

{isAllSelected && disabledMessage ? (
<FullGridAlert type="error" system>
{disabledMessage}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Permission Banner Missing for Partial Selection

The permission error banner for deleting replays only appears when all replays are selected. When only some replays are selected, the delete button is disabled, but the banner explaining the lack of permission doesn't show up, leaving users without an explanation for the disabled action.

Fix in Cursor Fix in Web

</FullGridAlert>
) : null}
</Fragment>
Expand All @@ -118,6 +158,7 @@ const TableCellFirst = styled(SimpleTable.HeaderCell)`
`;

const TableCellsRemaining = styled('div')`
margin-left: ${p => p.theme.space.lg};
display: flex;
align-items: center;
flex: 1;
Expand All @@ -126,4 +167,5 @@ const TableCellsRemaining = styled('div')`

const FullGridAlert = styled(Alert)`
grid-column: 1 / -1;
text-align: center;
`;
16 changes: 16 additions & 0 deletions static/app/utils/replays/hooks/useDeleteReplayHasAccess.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {hasEveryAccess} from 'sentry/components/acl/access';
import type {Project} from 'sentry/types/project';
import useOrganization from 'sentry/utils/useOrganization';

interface Props {
project: Project | null | undefined;
}

export default function useDeleteReplayHasAccess({project}: Props) {
const organization = useOrganization();

return (
hasEveryAccess(['project:write'], {organization, project}) ||
hasEveryAccess(['project:admin'], {organization, project})
);
}
Loading
Loading