diff --git a/static/app/components/replays/table/deleteReplays.tsx b/static/app/components/replays/table/deleteReplays.tsx index 9435280f191707..26d0a5669bcb2b 100644 --- a/static/app/components/replays/table/deleteReplays.tsx +++ b/static/app/components/replays/table/deleteReplays.tsx @@ -1,4 +1,4 @@ -import {Fragment, useCallback} from 'react'; +import {Fragment} from 'react'; import styled from '@emotion/styled'; import invariant from 'invariant'; @@ -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'; @@ -23,16 +22,14 @@ 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> | undefined; @@ -40,34 +37,16 @@ interface Props { 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`; @@ -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 ( - - - - ), - onConfirm: () => { - bulkDelete([deletePayload], { - onSuccess: () => { - addSuccessMessage( - tct('Replays are being deleted. [settings:View progress]', { - settings: , - }) - ); - // 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: () => {}, - }); + + ), + onConfirm: () => { + bulkDelete([deletePayload], { + onSuccess: () => { + addSuccessMessage( + tct('Replays are being deleted. [settings:View progress]', { + settings: , + }) + ); + queryClient.invalidateQueries({queryKey}); }, - }) - } - size="xs" - > - {t('Delete')} - - - + onError: () => + addErrorMessage( + tn( + 'Failed to delete replay', + 'Failed to delete replays', + selectedIds === 'all' ? Number.MAX_SAFE_INTEGER : selectedIds.length + ) + ), + onSettled: () => {}, + }); + }, + }) + } + size="xs" + > + {t('Delete')} + ); } diff --git a/static/app/components/replays/table/replayTableHeader.tsx b/static/app/components/replays/table/replayTableHeader.tsx index 2af3b95425389f..ef25aac35c28b8 100644 --- a/static/app/components/replays/table/replayTableHeader.tsx +++ b/static/app/components/replays/table/replayTableHeader.tsx @@ -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'; @@ -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 = { @@ -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 ( @@ -57,18 +93,24 @@ export default function ReplayTableHeader({columns, replays, onSortClick, sort}: /> - + + {project ? ( + + ) : null} + ) : null} {isAllSelected === 'indeterminate' ? ( - + {tn( 'Selected %s visible replay.', 'Selected %s visible replays.', @@ -87,21 +129,19 @@ export default function ReplayTableHeader({columns, replays, onSortClick, sort}: {isAllSelected === true ? ( - - - {queryString - ? tct('Selected all replays matching: [queryString].', { - queryString: {queryString}, - }) - : countSelected > replays.length - ? t('Selected all %s+ replays.', replays.length) - : tn( - 'Selected all %s replay.', - 'Selected all %s replays.', - countSelected - )} - - + {queryString + ? tct('Selected all replays matching: [queryString].', { + queryString: {queryString}, + }) + : countSelected > replays.length + ? t('Selected all %s+ replays.', replays.length) + : tn('Selected all %s replay.', 'Selected all %s replays.', countSelected)} + + ) : null} + + {isAllSelected && disabledMessage ? ( + + {disabledMessage} ) : null} @@ -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; @@ -126,4 +167,5 @@ const TableCellsRemaining = styled('div')` const FullGridAlert = styled(Alert)` grid-column: 1 / -1; + text-align: center; `; diff --git a/static/app/utils/replays/hooks/useDeleteReplayHasAccess.tsx b/static/app/utils/replays/hooks/useDeleteReplayHasAccess.tsx new file mode 100644 index 00000000000000..4beecd87d7ddec --- /dev/null +++ b/static/app/utils/replays/hooks/useDeleteReplayHasAccess.tsx @@ -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}) + ); +} diff --git a/static/app/utils/replays/hooks/useDeleteReplays.tsx b/static/app/utils/replays/hooks/useDeleteReplays.tsx index d447544e4e8630..757fca217186a2 100644 --- a/static/app/utils/replays/hooks/useDeleteReplays.tsx +++ b/static/app/utils/replays/hooks/useDeleteReplays.tsx @@ -1,22 +1,22 @@ import {useCallback} from 'react'; -import {hasEveryAccess} from 'sentry/components/acl/access'; import { getUtcValue, normalizeDateTimeParams, } from 'sentry/components/organizations/pageFilters/parse'; import {parseStatsPeriod} from 'sentry/components/timeRangeSelector/utils'; +import type {Project} from 'sentry/types/project'; import {getDateFromTimestamp, getDateWithTimezoneInUtc} from 'sentry/utils/dates'; import { fetchMutation, useMutation, type QueryKeyEndpointOptions, } from 'sentry/utils/queryClient'; +import useDeleteReplayHasAccess from 'sentry/utils/replays/hooks/useDeleteReplayHasAccess'; import useOrganization from 'sentry/utils/useOrganization'; -import useProjectFromSlug from 'sentry/utils/useProjectFromSlug'; interface Props { - projectSlug: string; + project: Project | null | undefined; } export type ReplayBulkDeletePayload = { @@ -28,18 +28,14 @@ export type ReplayBulkDeletePayload = { type Vars = [ReplayBulkDeletePayload]; -export default function useDeleteReplays({projectSlug}: Props) { +export default function useDeleteReplays({project}: Props) { const organization = useOrganization(); - const project = useProjectFromSlug({organization, projectSlug}); - const hasWriteAccess = hasEveryAccess(['project:write'], {organization, project}); - const hasAdminAccess = hasEveryAccess(['project:admin'], {organization, project}); - - const hasAccess = Boolean(projectSlug) && (hasWriteAccess || hasAdminAccess); + const hasAccess = useDeleteReplayHasAccess({project}); const {mutate} = useMutation({ mutationFn: ([data]: Vars) => { - if (!projectSlug) { - throw new Error('Project ID or slug is required'); + if (!project) { + throw new Error('Project is required'); } if (!hasAccess) { throw new Error('User does not have permission to delete replays'); @@ -49,7 +45,7 @@ export default function useDeleteReplays({projectSlug}: Props) { const payload = {data}; return fetchMutation({ method: 'POST', - url: `/projects/${organization.slug}/${projectSlug}/replays/jobs/delete/`, + url: `/projects/${organization.slug}/${project.slug}/replays/jobs/delete/`, options, data: payload, }); diff --git a/static/app/utils/replays/useDeleteReplays.spec.tsx b/static/app/utils/replays/useDeleteReplays.spec.tsx index 91e0d32b004ea8..4a8a7c9bbd3892 100644 --- a/static/app/utils/replays/useDeleteReplays.spec.tsx +++ b/static/app/utils/replays/useDeleteReplays.spec.tsx @@ -9,7 +9,6 @@ import useDeleteReplays from 'sentry/utils/replays/hooks/useDeleteReplays'; describe('useDeleteReplays', () => { describe('queryOptionsToPayload', () => { const project = ProjectFixture(); - const projectSlug = project.slug; beforeEach(() => { const configstate = ConfigStore.getState(); @@ -33,7 +32,7 @@ describe('useDeleteReplays', () => { it('should parse a an empty queryOptions into default 14d rangeStart & rangeEnd', () => { const {result} = renderHookWithProviders(useDeleteReplays, { - initialProps: {projectSlug}, + initialProps: {project}, }); expect(result.current.queryOptionsToPayload(['1', '2'], {})).toEqual({ @@ -46,7 +45,7 @@ describe('useDeleteReplays', () => { it('should parse a statsPeriod into rangeStart & rangeEnd', () => { const {result} = renderHookWithProviders(useDeleteReplays, { - initialProps: {projectSlug}, + initialProps: {project}, }); expect( @@ -63,7 +62,7 @@ describe('useDeleteReplays', () => { it('should parse a start & end into rangeStart & rangeEnd', () => { const {result} = renderHookWithProviders(useDeleteReplays, { - initialProps: {projectSlug}, + initialProps: {project}, }); // Users timezone: 2:41 becomes 6:41 UTC