Skip to content

Commit 8d87891

Browse files
committed
fix(onboarding): Handle existing projects and loading races in SCM flow
When the user navigates back from setup-docs after the project has already received events, the project is not deleted. Handle this by detecting the existing project via createdProjectSlug and persisted form state: - Platform changed: abandon old project, create new (matching legacy) - No changes: reuse existing project as-is - Name/team changed: update existing project in place via PUT Also guard the Create button against teams and projects still loading so the existence check cannot be bypassed by a race.
1 parent f1ef345 commit 8d87891

File tree

2 files changed

+120
-17
lines changed

2 files changed

+120
-17
lines changed

static/app/views/onboarding/scmProjectDetails.spec.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
OnboardingContextProvider,
1010
type OnboardingSessionState,
1111
} from 'sentry/components/onboarding/onboardingContext';
12+
import {ProjectsStore} from 'sentry/stores/projectsStore';
1213
import {TeamStore} from 'sentry/stores/teamStore';
1314
import type {OnboardingSelectedSDK} from 'sentry/types/onboarding';
1415
import * as analytics from 'sentry/utils/analytics';
@@ -44,6 +45,7 @@ describe('ScmProjectDetails', () => {
4445
beforeEach(() => {
4546
sessionStorageWrapper.clear();
4647
TeamStore.loadInitialData([teamWithAccess]);
48+
ProjectsStore.loadInitialData([]);
4749

4850
// useCreateNotificationAction queries messaging integrations on mount
4951
MockApiClient.addMockResponse({

static/app/views/onboarding/scmProjectDetails.tsx

Lines changed: 118 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout';
77
import {Text} from '@sentry/scraps/text';
88

99
import {addErrorMessage} from 'sentry/actionCreators/indicator';
10+
import {update} from 'sentry/actionCreators/projects';
1011
import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext';
1112
import {useCreateProjectAndRules} from 'sentry/components/onboarding/useCreateProjectAndRules';
13+
import {useCreateProjectRules} from 'sentry/components/onboarding/useCreateProjectRules';
1214
import {TeamSelector} from 'sentry/components/teamSelector';
1315
import {IconGroup, IconProject, IconSiren} from 'sentry/icons';
1416
import {t} from 'sentry/locale';
17+
import type {OnboardingSelectedSDK} from 'sentry/types/onboarding';
1518
import type {Team} from 'sentry/types/organization';
1619
import {trackAnalytics} from 'sentry/utils/analytics';
1720
import {slugify} from 'sentry/utils/slugify';
21+
import {useApi} from 'sentry/utils/useApi';
1822
import {useOrganization} from 'sentry/utils/useOrganization';
23+
import {useProjects} from 'sentry/utils/useProjects';
1924
import {useTeams} from 'sentry/utils/useTeams';
2025
import {
2126
DEFAULT_ISSUE_ALERT_OPTIONS_VALUES,
@@ -32,16 +37,20 @@ import type {StepProps} from './types';
3237
const PROJECT_DETAILS_WIDTH = '285px';
3338

3439
export function ScmProjectDetails({onComplete}: StepProps) {
40+
const api = useApi();
3541
const organization = useOrganization();
3642
const {
3743
selectedPlatform,
3844
selectedFeatures,
45+
createdProjectSlug,
3946
setCreatedProjectSlug,
4047
projectDetailsForm,
4148
setProjectDetailsForm,
4249
} = useOnboardingContext();
43-
const {teams} = useTeams();
50+
const {teams, fetching: isLoadingTeams} = useTeams();
51+
const {projects, initiallyLoaded: projectsLoaded} = useProjects();
4452
const createProjectAndRules = useCreateProjectAndRules();
53+
const createProjectRules = useCreateProjectRules();
4554
useEffect(() => {
4655
trackAnalytics('onboarding.scm_project_details_step_viewed', {organization});
4756
}, [organization]);
@@ -58,6 +67,12 @@ export function ScmProjectDetails({onComplete}: StepProps) {
5867
projectDetailsForm?.teamSlug ?? null
5968
);
6069

70+
const [isSaving, setIsSaving] = useState(false);
71+
72+
// Safety-net: verify the previously created project still exists in the store.
73+
const projectExists =
74+
!!createdProjectSlug && projects.some(p => p.slug === createdProjectSlug);
75+
6176
const projectNameResolved = projectName ?? defaultName;
6277
const teamSlugResolved = teamSlug ?? firstAdminTeam?.slug ?? '';
6378

@@ -104,34 +119,35 @@ export function ScmProjectDetails({onComplete}: StepProps) {
104119
projectNameResolved.length > 0 &&
105120
teamSlugResolved.length > 0 &&
106121
!!selectedPlatform &&
107-
!createProjectAndRules.isPending;
122+
!isSaving &&
123+
!isLoadingTeams &&
124+
projectsLoaded;
108125

109-
async function handleCreateProject() {
110-
if (!selectedPlatform || !canSubmit) {
126+
function persistFormState() {
127+
if (!selectedPlatform) {
111128
return;
112129
}
130+
setProjectDetailsForm({
131+
projectName: projectNameResolved,
132+
teamSlug: teamSlugResolved,
133+
alertRuleConfig,
134+
platform: selectedPlatform.key,
135+
});
136+
}
113137

114-
trackAnalytics('onboarding.scm_project_details_create_clicked', {organization});
115-
138+
async function createNewProject(platform: OnboardingSelectedSDK) {
139+
setIsSaving(true);
116140
try {
117141
const {project} = await createProjectAndRules.mutateAsync({
118142
projectName: projectNameResolved,
119-
platform: selectedPlatform,
143+
platform,
120144
team: teamSlugResolved,
121145
alertRuleConfig: getRequestDataFragment(alertRuleConfig),
122146
createNotificationAction: () => undefined,
123147
});
124148

125-
// Store the project slug separately so onboarding.tsx can find
126-
// the project via useRecentCreatedProject without corrupting
127-
// selectedPlatform.key (which the platform features step needs).
128149
setCreatedProjectSlug(project.slug);
129-
setProjectDetailsForm({
130-
projectName: projectNameResolved,
131-
teamSlug: teamSlugResolved,
132-
alertRuleConfig,
133-
platform: selectedPlatform.key,
134-
});
150+
persistFormState();
135151

136152
trackAnalytics('onboarding.scm_project_details_create_succeeded', {
137153
organization,
@@ -143,6 +159,91 @@ export function ScmProjectDetails({onComplete}: StepProps) {
143159
trackAnalytics('onboarding.scm_project_details_create_failed', {organization});
144160
addErrorMessage(t('Failed to create project'));
145161
Sentry.captureException(error);
162+
} finally {
163+
setIsSaving(false);
164+
}
165+
}
166+
167+
async function updateExistingProject(slug: string) {
168+
setIsSaving(true);
169+
try {
170+
const updated = await update(api, {
171+
orgId: organization.slug,
172+
projectId: slug,
173+
data: {name: projectNameResolved},
174+
});
175+
176+
// If the alert config changed, replace the existing rule.
177+
// Onboarding only ever creates a single alert rule.
178+
if (!alertConfigUnchanged) {
179+
const [existingRule] = await api.requestPromise(
180+
`/projects/${organization.slug}/${updated.slug}/rules/`
181+
);
182+
if (existingRule) {
183+
await api.requestPromise(
184+
`/projects/${organization.slug}/${updated.slug}/rules/${existingRule.id}/`,
185+
{method: 'DELETE'}
186+
);
187+
}
188+
189+
const fragment = getRequestDataFragment(alertRuleConfig);
190+
if (fragment.shouldCreateCustomRule) {
191+
await createProjectRules.mutateAsync({
192+
projectSlug: updated.slug,
193+
name: updated.name,
194+
conditions: fragment.conditions,
195+
actions: fragment.actions,
196+
actionMatch: fragment.actionMatch,
197+
frequency: fragment.frequency,
198+
});
199+
}
200+
}
201+
202+
setCreatedProjectSlug(updated.slug);
203+
persistFormState();
204+
onComplete(undefined, selectedFeatures ? {product: selectedFeatures} : undefined);
205+
} catch (error) {
206+
addErrorMessage(t('Failed to update project'));
207+
Sentry.captureException(error);
208+
} finally {
209+
setIsSaving(false);
210+
}
211+
}
212+
213+
const savedAlert = projectDetailsForm?.alertRuleConfig;
214+
const alertConfigUnchanged =
215+
alertRuleConfig.alertSetting === savedAlert?.alertSetting &&
216+
alertRuleConfig.interval === savedAlert?.interval &&
217+
alertRuleConfig.metric === savedAlert?.metric &&
218+
alertRuleConfig.threshold === savedAlert?.threshold;
219+
220+
function handleCreateProject() {
221+
if (!selectedPlatform || !canSubmit) {
222+
return;
223+
}
224+
225+
trackAnalytics('onboarding.scm_project_details_create_clicked', {organization});
226+
227+
if (projectExists && createdProjectSlug) {
228+
const platformChanged = projectDetailsForm?.platform !== selectedPlatform.key;
229+
230+
if (platformChanged) {
231+
// Platform changed — abandon the old project and create a new one,
232+
// matching legacy onboarding behavior.
233+
createNewProject(selectedPlatform);
234+
} else if (
235+
projectNameResolved === projectDetailsForm?.projectName &&
236+
teamSlugResolved === projectDetailsForm?.teamSlug &&
237+
alertConfigUnchanged
238+
) {
239+
// Nothing changed — reuse the existing project as-is.
240+
onComplete(undefined, selectedFeatures ? {product: selectedFeatures} : undefined);
241+
} else {
242+
// Same platform but settings changed — update in place.
243+
updateExistingProject(createdProjectSlug);
244+
}
245+
} else {
246+
createNewProject(selectedPlatform);
146247
}
147248
}
148249

@@ -218,7 +319,7 @@ export function ScmProjectDetails({onComplete}: StepProps) {
218319
priority="primary"
219320
onClick={handleCreateProject}
220321
disabled={!canSubmit}
221-
busy={createProjectAndRules.isPending}
322+
busy={isSaving}
222323
icon={<IconProject />}
223324
>
224325
{t('Create project')}

0 commit comments

Comments
 (0)