From 044009f687926f1281a0c339f13ac307917e0233 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 21 May 2025 14:50:12 +0200 Subject: [PATCH 1/4] fix(project-install): Render request errors on the bottom if any --- .../views/projectInstall/createProject.tsx | 107 +++++++++++++++--- 1 file changed, 90 insertions(+), 17 deletions(-) diff --git a/static/app/views/projectInstall/createProject.tsx b/static/app/views/projectInstall/createProject.tsx index 724806e4f089ee..b80cb11e1895ec 100644 --- a/static/app/views/projectInstall/createProject.tsx +++ b/static/app/views/projectInstall/createProject.tsx @@ -7,6 +7,8 @@ import {PlatformIcon} from 'platformicons'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openModal} from 'sentry/actionCreators/modal'; +import {removeProject} from 'sentry/actionCreators/projects'; +import type {Client} from 'sentry/api'; import Access from 'sentry/components/acl/access'; import {Alert} from 'sentry/components/core/alert'; import {Button} from 'sentry/components/core/button'; @@ -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'; @@ -130,10 +133,70 @@ const keyToErrorText: Record = { detail: t('Project details'), }; +async function rollbackRulesAndProject({ + api, + orgSlug, + projSlug, + customRuleId, + notificationRuleId, +}: { + api: Client; + orgSlug: string; + customRuleId?: string; + notificationRuleId?: string; + projSlug?: string; +}) { + if (!projSlug) { + return; + } + + if (notificationRuleId) { + try { + await api.requestPromise( + `/projects/${orgSlug}/${projSlug}/rules/${notificationRuleId}/`, + {method: 'DELETE'} + ); + } catch (error) { + Sentry.withScope(scope => { + scope.setExtra('error', error); + Sentry.captureMessage('Failed to rollback notification rule'); + }); + } + } + + if (customRuleId) { + try { + await api.requestPromise( + `/projects/${orgSlug}/${projSlug}/rules/${customRuleId}/`, + {method: 'DELETE'} + ); + } catch (error) { + Sentry.withScope(scope => { + scope.setExtra('error', error); + Sentry.captureMessage('Failed to rollback custom rule'); + }); + } + } + + try { + await removeProject({ + api, + orgSlug, + projectSlug: projSlug, + origin: 'getting_started', + }); + } catch (error) { + Sentry.withScope(scope => { + scope.setExtra('error', error); + Sentry.captureMessage('Failed to rollback project'); + }); + } +} + 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(); @@ -153,10 +216,10 @@ export function CreateProject() { project, alertRuleConfig, }: {project: Project} & Pick) => { - const ruleIds = []; + let ruleData: undefined | {id: string}; if (alertRuleConfig?.shouldCreateCustomRule) { - const ruleData = await api.requestPromise( + ruleData = await api.requestPromise( `/projects/${organization.slug}/${project.slug}/rules/`, { method: 'POST', @@ -169,8 +232,6 @@ export function CreateProject() { }, } ); - - ruleIds.push(ruleData.id); } const notificationRule = await createNotificationAction({ @@ -182,11 +243,10 @@ export function CreateProject() { frequency: alertRuleConfig?.frequency, }); - if (notificationRule) { - ruleIds.push(notificationRule.id); - } - - return ruleIds; + return { + customRuleId: ruleData?.id, + notificationRuleId: notificationRule?.id, + }; }, [organization, api, createNotificationAction] ); @@ -283,15 +343,21 @@ export function CreateProject() { return; } + let project: Project | undefined = undefined; + let customRuleId: string | undefined = undefined; + let notificationRuleId: string | undefined = undefined; + try { - const project = await createProject.mutateAsync({ + project = await createProject.mutateAsync({ name: projectName, platform: selectedPlatform, default_rules: alertRuleConfig?.defaultRules ?? true, firstTeamSlug: team, }); - const ruleIds = await createRules({project, alertRuleConfig}); + const rules = await createRules({alertRuleConfig, project}); + customRuleId = rules.customRuleId; + notificationRuleId = rules.notificationRuleId; trackAnalytics('project_creation_page.created', { organization, @@ -302,7 +368,7 @@ export function CreateProject() { : 'No Rule', project_id: project.id, platform: selectedPlatform.key, - rule_ids: ruleIds, + rule_ids: [customRuleId, notificationRuleId].filter(defined), }); addSuccessMessage( @@ -339,9 +405,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 +414,19 @@ export function CreateProject() { Sentry.captureMessage('Project creation failed'); }); } + setErrors(error.responseJSON); + addErrorMessage(t('Failed to create project %s', `${projectName}`)); + + rollbackRulesAndProject({ + projSlug: project?.slug, + orgSlug: organization.slug, + api, + customRuleId, + notificationRuleId, + }); } }, - [createRules, organization, createProject, setCreatedProject, navigate] + [organization, createProject, setCreatedProject, navigate, api, createRules] ); const handleProjectCreation = useCallback( From 10e3afc7c89ebc63c07cc879207f983647229a68 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 21 May 2025 15:19:47 +0200 Subject: [PATCH 2/4] disable primary button if loading --- static/app/views/projectInstall/createProject.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/app/views/projectInstall/createProject.tsx b/static/app/views/projectInstall/createProject.tsx index b80cb11e1895ec..8320f560a86236 100644 --- a/static/app/views/projectInstall/createProject.tsx +++ b/static/app/views/projectInstall/createProject.tsx @@ -197,6 +197,7 @@ export function CreateProject() { const api = useApi(); const navigate = useNavigate(); const [errors, setErrors] = useState(); + const [loading, setLoading] = useState(false); const organization = useOrganization(); const location = useLocation(); const {createNotificationAction, notificationProps} = useCreateNotificationAction(); @@ -298,7 +299,7 @@ export function CreateProject() { ].filter(value => value).length; const canSubmitForm = - !createProject.isPending && canUserCreateProject && formErrorCount === 0; + !loading && !createProject.isPending && canUserCreateProject && formErrorCount === 0; const submitTooltipText = getSubmitTooltipText({ ...missingValues, @@ -348,6 +349,7 @@ export function CreateProject() { let notificationRuleId: string | undefined = undefined; try { + setLoading(true); project = await createProject.mutateAsync({ name: projectName, platform: selectedPlatform, @@ -424,6 +426,8 @@ export function CreateProject() { customRuleId, notificationRuleId, }); + } finally { + setLoading(false); } }, [organization, createProject, setCreatedProject, navigate, api, createRules] From 527c6269fec990a44f7d2def4251be500a0dc323 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 21 May 2025 15:21:30 +0200 Subject: [PATCH 3/4] submitting is a better word --- static/app/views/projectInstall/createProject.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/static/app/views/projectInstall/createProject.tsx b/static/app/views/projectInstall/createProject.tsx index 8320f560a86236..82d1b389968f52 100644 --- a/static/app/views/projectInstall/createProject.tsx +++ b/static/app/views/projectInstall/createProject.tsx @@ -197,7 +197,7 @@ export function CreateProject() { const api = useApi(); const navigate = useNavigate(); const [errors, setErrors] = useState(); - const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); const organization = useOrganization(); const location = useLocation(); const {createNotificationAction, notificationProps} = useCreateNotificationAction(); @@ -299,7 +299,10 @@ export function CreateProject() { ].filter(value => value).length; const canSubmitForm = - !loading && !createProject.isPending && canUserCreateProject && formErrorCount === 0; + !submitting && + !createProject.isPending && + canUserCreateProject && + formErrorCount === 0; const submitTooltipText = getSubmitTooltipText({ ...missingValues, @@ -349,7 +352,7 @@ export function CreateProject() { let notificationRuleId: string | undefined = undefined; try { - setLoading(true); + setSubmitting(true); project = await createProject.mutateAsync({ name: projectName, platform: selectedPlatform, @@ -427,7 +430,7 @@ export function CreateProject() { notificationRuleId, }); } finally { - setLoading(false); + setSubmitting(false); } }, [organization, createProject, setCreatedProject, navigate, api, createRules] From 8d52a76f5c2c3a05a4617d91be81b8b0c000a538 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 22 May 2025 08:35:29 +0200 Subject: [PATCH 4/4] use more react-query --- .../onboarding/useCreateProjectRules.tsx | 36 +++++ .../views/projectInstall/createProject.tsx | 141 +++++------------- .../issueAlertNotificationOptions.tsx | 34 ++--- .../projectInstall/issueAlertOptions.tsx | 6 +- 4 files changed, 92 insertions(+), 125 deletions(-) create mode 100644 static/app/components/onboarding/useCreateProjectRules.tsx 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 82d1b389968f52..60c705bd35d6c6 100644 --- a/static/app/views/projectInstall/createProject.tsx +++ b/static/app/views/projectInstall/createProject.tsx @@ -8,7 +8,6 @@ import {PlatformIcon} from 'platformicons'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openModal} from 'sentry/actionCreators/modal'; import {removeProject} from 'sentry/actionCreators/projects'; -import type {Client} from 'sentry/api'; import Access from 'sentry/components/acl/access'; import {Alert} from 'sentry/components/core/alert'; import {Button} from 'sentry/components/core/button'; @@ -20,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'; @@ -133,76 +133,16 @@ const keyToErrorText: Record = { detail: t('Project details'), }; -async function rollbackRulesAndProject({ - api, - orgSlug, - projSlug, - customRuleId, - notificationRuleId, -}: { - api: Client; - orgSlug: string; - customRuleId?: string; - notificationRuleId?: string; - projSlug?: string; -}) { - if (!projSlug) { - return; - } - - if (notificationRuleId) { - try { - await api.requestPromise( - `/projects/${orgSlug}/${projSlug}/rules/${notificationRuleId}/`, - {method: 'DELETE'} - ); - } catch (error) { - Sentry.withScope(scope => { - scope.setExtra('error', error); - Sentry.captureMessage('Failed to rollback notification rule'); - }); - } - } - - if (customRuleId) { - try { - await api.requestPromise( - `/projects/${orgSlug}/${projSlug}/rules/${customRuleId}/`, - {method: 'DELETE'} - ); - } catch (error) { - Sentry.withScope(scope => { - scope.setExtra('error', error); - Sentry.captureMessage('Failed to rollback custom rule'); - }); - } - } - - try { - await removeProject({ - api, - orgSlug, - projectSlug: projSlug, - origin: 'getting_started', - }); - } catch (error) { - Sentry.withScope(scope => { - scope.setExtra('error', error); - Sentry.captureMessage('Failed to rollback project'); - }); - } -} - export function CreateProject() { const api = useApi(); const navigate = useNavigate(); const [errors, setErrors] = useState(); - const [submitting, setSubmitting] = useState(false); 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); @@ -217,22 +157,19 @@ export function CreateProject() { project, alertRuleConfig, }: {project: Project} & Pick) => { - let ruleData: undefined | {id: string}; + const ruleIds: Array = []; if (alertRuleConfig?.shouldCreateCustomRule) { - 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(customRule.id); } const notificationRule = await createNotificationAction({ @@ -244,12 +181,11 @@ export function CreateProject() { frequency: alertRuleConfig?.frequency, }); - return { - customRuleId: ruleData?.id, - notificationRuleId: notificationRule?.id, - }; + ruleIds.push(notificationRule?.id); + + return ruleIds.filter(defined); }, - [organization, api, createNotificationAction] + [createNotificationAction, createProjectRules] ); const autoFill = useMemo(() => { @@ -299,7 +235,7 @@ export function CreateProject() { ].filter(value => value).length; const canSubmitForm = - !submitting && + !createProjectRules.isPending && !createProject.isPending && canUserCreateProject && formErrorCount === 0; @@ -347,22 +283,19 @@ export function CreateProject() { return; } - let project: Project | undefined = undefined; - let customRuleId: string | undefined = undefined; - let notificationRuleId: string | undefined = undefined; + let projectToRollback: Project | undefined; try { - setSubmitting(true); - project = await createProject.mutateAsync({ + const project = await createProject.mutateAsync({ name: projectName, platform: selectedPlatform, default_rules: alertRuleConfig?.defaultRules ?? true, firstTeamSlug: team, }); - const rules = await createRules({alertRuleConfig, project}); - customRuleId = rules.customRuleId; - notificationRuleId = rules.notificationRuleId; + projectToRollback = project; + + const ruleIds = await createRules({alertRuleConfig, project}); trackAnalytics('project_creation_page.created', { organization, @@ -373,7 +306,7 @@ export function CreateProject() { : 'No Rule', project_id: project.id, platform: selectedPlatform.key, - rule_ids: [customRuleId, notificationRuleId].filter(defined), + rule_ids: ruleIds, }); addSuccessMessage( @@ -422,15 +355,23 @@ export function CreateProject() { setErrors(error.responseJSON); addErrorMessage(t('Failed to create project %s', `${projectName}`)); - rollbackRulesAndProject({ - projSlug: project?.slug, - orgSlug: organization.slug, - api, - customRuleId, - notificationRuleId, - }); - } finally { - setSubmitting(false); + 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'); + }); + } + } } }, [organization, createProject, setCreatedProject, navigate, api, createRules] 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 {