diff --git a/static/app/components/onboarding/useCreateProjectRules.tsx b/static/app/components/onboarding/useCreateProjectRules.tsx new file mode 100644 index 00000000000000..039c6ab532aa7e --- /dev/null +++ b/static/app/components/onboarding/useCreateProjectRules.tsx @@ -0,0 +1,36 @@ +import {Project} from 'sentry/types/project'; +import {useMutation} from 'sentry/utils/queryClient'; +import RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; +import {RequestDataFragment} from 'sentry/views/projectInstall/issueAlertOptions'; + +interface Variables + extends Partial< + Pick< + RequestDataFragment, + 'conditions' | 'actions' | 'actionMatch' | 'frequency' | 'name' + > + > { + projectSlug: string; +} + +export function useCreateProjectRules() { + const api = useApi(); + const organization = useOrganization(); + + return useMutation({ + mutationFn: ({projectSlug, name, conditions, actions, actionMatch, frequency}) => { + return api.requestPromise(`/projects/${organization.slug}/${projectSlug}/rules/`, { + method: 'POST', + data: { + name, + conditions, + actions, + actionMatch, + frequency, + }, + }); + }, + }); +} diff --git a/static/app/views/projectInstall/createProject.tsx b/static/app/views/projectInstall/createProject.tsx index 724806e4f089ee..60c705bd35d6c6 100644 --- a/static/app/views/projectInstall/createProject.tsx +++ b/static/app/views/projectInstall/createProject.tsx @@ -7,6 +7,7 @@ import {PlatformIcon} from 'platformicons'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openModal} from 'sentry/actionCreators/modal'; +import {removeProject} from 'sentry/actionCreators/projects'; import Access from 'sentry/components/acl/access'; import {Alert} from 'sentry/components/core/alert'; import {Button} from 'sentry/components/core/button'; @@ -18,6 +19,7 @@ import List from 'sentry/components/list'; import ListItem from 'sentry/components/list/listItem'; import {SupportedLanguages} from 'sentry/components/onboarding/frameworkSuggestionModal'; import {useCreateProject} from 'sentry/components/onboarding/useCreateProject'; +import {useCreateProjectRules} from 'sentry/components/onboarding/useCreateProjectRules'; import type {Platform} from 'sentry/components/platformPicker'; import PlatformPicker from 'sentry/components/platformPicker'; import TeamSelector from 'sentry/components/teamSelector'; @@ -26,6 +28,7 @@ import {space} from 'sentry/styles/space'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {Team} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {decodeScalar} from 'sentry/utils/queryString'; import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; @@ -133,12 +136,13 @@ const keyToErrorText: Record = { export function CreateProject() { const api = useApi(); const navigate = useNavigate(); - const [errors, setErrors] = useState(false); + const [errors, setErrors] = useState(); const organization = useOrganization(); const location = useLocation(); const {createNotificationAction, notificationProps} = useCreateNotificationAction(); const canUserCreateProject = useCanCreateProject(); const createProject = useCreateProject(); + const createProjectRules = useCreateProjectRules(); const {teams} = useTeams(); const accessTeams = teams.filter((team: Team) => team.access.includes('team:admin')); const referrer = decodeScalar(location.query.referrer); @@ -153,24 +157,19 @@ export function CreateProject() { project, alertRuleConfig, }: {project: Project} & Pick) => { - const ruleIds = []; + const ruleIds: Array = []; if (alertRuleConfig?.shouldCreateCustomRule) { - const ruleData = await api.requestPromise( - `/projects/${organization.slug}/${project.slug}/rules/`, - { - method: 'POST', - data: { - name: project.name, - conditions: alertRuleConfig?.conditions, - actions: alertRuleConfig?.actions, - actionMatch: alertRuleConfig?.actionMatch, - frequency: alertRuleConfig?.frequency, - }, - } - ); + const customRule = await createProjectRules.mutateAsync({ + projectSlug: project.slug, + name: project.name, + actions: alertRuleConfig?.actions, + conditions: alertRuleConfig?.conditions, + actionMatch: alertRuleConfig?.actionMatch, + frequency: alertRuleConfig?.frequency, + }); - ruleIds.push(ruleData.id); + ruleIds.push(customRule.id); } const notificationRule = await createNotificationAction({ @@ -182,13 +181,11 @@ export function CreateProject() { frequency: alertRuleConfig?.frequency, }); - if (notificationRule) { - ruleIds.push(notificationRule.id); - } + ruleIds.push(notificationRule?.id); - return ruleIds; + return ruleIds.filter(defined); }, - [organization, api, createNotificationAction] + [createNotificationAction, createProjectRules] ); const autoFill = useMemo(() => { @@ -238,7 +235,10 @@ export function CreateProject() { ].filter(value => value).length; const canSubmitForm = - !createProject.isPending && canUserCreateProject && formErrorCount === 0; + !createProjectRules.isPending && + !createProject.isPending && + canUserCreateProject && + formErrorCount === 0; const submitTooltipText = getSubmitTooltipText({ ...missingValues, @@ -283,6 +283,8 @@ export function CreateProject() { return; } + let projectToRollback: Project | undefined; + try { const project = await createProject.mutateAsync({ name: projectName, @@ -291,7 +293,9 @@ export function CreateProject() { firstTeamSlug: team, }); - const ruleIds = await createRules({project, alertRuleConfig}); + projectToRollback = project; + + const ruleIds = await createRules({alertRuleConfig, project}); trackAnalytics('project_creation_page.created', { organization, @@ -339,9 +343,6 @@ export function CreateProject() { ) ); } catch (error) { - setErrors(!!error.responseJSON); - addErrorMessage(t('Failed to create project %s', `${projectName}`)); - // Only log this if the error is something other than: // * The user not having access to create a project, or, // * A project with that slug already exists @@ -351,9 +352,29 @@ export function CreateProject() { Sentry.captureMessage('Project creation failed'); }); } + setErrors(error.responseJSON); + addErrorMessage(t('Failed to create project %s', `${projectName}`)); + + if (projectToRollback) { + try { + // Rolling back the project also deletes its associated alert rules + // due to the cascading delete constraint. + await removeProject({ + api, + orgSlug: organization.slug, + projectSlug: projectToRollback.slug, + origin: 'getting_started', + }); + } catch (err) { + Sentry.withScope(scope => { + scope.setExtra('error', err); + Sentry.captureMessage('Failed to rollback project'); + }); + } + } } }, - [createRules, organization, createProject, setCreatedProject, navigate] + [organization, createProject, setCreatedProject, navigate, api, createRules] ); const handleProjectCreation = useCallback( diff --git a/static/app/views/projectInstall/issueAlertNotificationOptions.tsx b/static/app/views/projectInstall/issueAlertNotificationOptions.tsx index 3f2518a572f142..119ac51655c0b3 100644 --- a/static/app/views/projectInstall/issueAlertNotificationOptions.tsx +++ b/static/app/views/projectInstall/issueAlertNotificationOptions.tsx @@ -2,17 +2,18 @@ import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import MultipleCheckbox from 'sentry/components/forms/controls/multipleCheckbox'; +import {useCreateProjectRules} from 'sentry/components/onboarding/useCreateProjectRules'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {type IntegrationAction, IssueAlertActionType} from 'sentry/types/alerts'; import type {OrganizationIntegration} from 'sentry/types/integrations'; import {useApiQuery} from 'sentry/utils/queryClient'; import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; -import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import SetupMessagingIntegrationButton, { MessagingIntegrationAnalyticsView, } from 'sentry/views/alerts/rules/issue/setupMessagingIntegrationButton'; +import {RequestDataFragment} from 'sentry/views/projectInstall/issueAlertOptions'; import MessagingIntegrationAlertRule from 'sentry/views/projectInstall/messagingIntegrationAlertRule'; export const providerDetails = { @@ -77,8 +78,8 @@ export type IssueAlertNotificationProps = { }; export function useCreateNotificationAction() { - const api = useApi(); const organization = useOrganization(); + const createProjectRules = useCreateProjectRules(); const messagingIntegrationsQuery = useApiQuery( [`/organizations/${organization.slug}/integrations/?integrationType=messaging`], @@ -120,15 +121,6 @@ export function useCreateNotificationAction() { } }, [messagingIntegrationsQuery.isSuccess, providersToIntegrations]); - type Props = { - actionMatch: string | undefined; - conditions: Array<{id: string; interval: string; value: string}> | undefined; - frequency: number | undefined; - name: string | undefined; - projectSlug: string; - shouldCreateRule: boolean | undefined; - }; - const createNotificationAction = useCallback( ({ shouldCreateRule, @@ -137,7 +129,7 @@ export function useCreateNotificationAction() { conditions, actionMatch, frequency, - }: Props) => { + }: Partial & {projectSlug: string}) => { const isCreatingIntegrationNotification = actions.find( action => action === MultipleCheckboxOptions.INTEGRATION ); @@ -174,18 +166,16 @@ export function useCreateNotificationAction() { return undefined; } - return api.requestPromise(`/projects/${organization.slug}/${projectSlug}/rules/`, { - method: 'POST', - data: { - name, - conditions, - actions: [integrationAction], - actionMatch, - frequency, - }, + return createProjectRules.mutateAsync({ + projectSlug, + name, + conditions, + actions: [integrationAction], + actionMatch, + frequency, }); }, - [actions, api, provider, integration, channel, organization.slug] + [actions, provider, integration, channel, createProjectRules] ); return { diff --git a/static/app/views/projectInstall/issueAlertOptions.tsx b/static/app/views/projectInstall/issueAlertOptions.tsx index 630338cc5bc4c1..5a66c986265212 100644 --- a/static/app/views/projectInstall/issueAlertOptions.tsx +++ b/static/app/views/projectInstall/issueAlertOptions.tsx @@ -62,14 +62,14 @@ const INTERVAL_CHOICES = [ ]; export type RequestDataFragment = { - actionMatch: string; actions: Array>; conditions: Array<{id: string; interval: string; value: string}> | undefined; defaultRules: boolean; - frequency: number; - name: string; shouldCreateCustomRule: boolean; shouldCreateRule: boolean; + actionMatch?: string; + frequency?: number; + name?: string; }; export interface IssueAlertOptionsProps {