Skip to content

Commit 0c9ada8

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 0c9ada8

File tree

2 files changed

+139
-17
lines changed

2 files changed

+139
-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: 137 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,28 @@ 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 {IssueAlertRule} from 'sentry/types/alerts';
18+
import type {OnboardingSelectedSDK} from 'sentry/types/onboarding';
1519
import type {Team} from 'sentry/types/organization';
1620
import {trackAnalytics} from 'sentry/utils/analytics';
21+
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
22+
import {
23+
type ApiQueryKey,
24+
fetchDataQuery,
25+
fetchMutation,
26+
useQueryClient,
27+
} from 'sentry/utils/queryClient';
1728
import {slugify} from 'sentry/utils/slugify';
29+
import {useApi} from 'sentry/utils/useApi';
1830
import {useOrganization} from 'sentry/utils/useOrganization';
31+
import {useProjects} from 'sentry/utils/useProjects';
1932
import {useTeams} from 'sentry/utils/useTeams';
2033
import {
2134
DEFAULT_ISSUE_ALERT_OPTIONS_VALUES,
@@ -32,16 +45,21 @@ import type {StepProps} from './types';
3245
const PROJECT_DETAILS_WIDTH = '285px';
3346

3447
export function ScmProjectDetails({onComplete}: StepProps) {
48+
const api = useApi();
49+
const queryClient = useQueryClient();
3550
const organization = useOrganization();
3651
const {
3752
selectedPlatform,
3853
selectedFeatures,
54+
createdProjectSlug,
3955
setCreatedProjectSlug,
4056
projectDetailsForm,
4157
setProjectDetailsForm,
4258
} = useOnboardingContext();
43-
const {teams} = useTeams();
59+
const {teams, fetching: isLoadingTeams} = useTeams();
60+
const {projects, initiallyLoaded: projectsLoaded} = useProjects();
4461
const createProjectAndRules = useCreateProjectAndRules();
62+
const createProjectRules = useCreateProjectRules();
4563
useEffect(() => {
4664
trackAnalytics('onboarding.scm_project_details_step_viewed', {organization});
4765
}, [organization]);
@@ -58,6 +76,12 @@ export function ScmProjectDetails({onComplete}: StepProps) {
5876
projectDetailsForm?.teamSlug ?? null
5977
);
6078

79+
const [isSaving, setIsSaving] = useState(false);
80+
81+
// Safety-net: verify the previously created project still exists in the store.
82+
const projectExists =
83+
!!createdProjectSlug && projects.some(p => p.slug === createdProjectSlug);
84+
6185
const projectNameResolved = projectName ?? defaultName;
6286
const teamSlugResolved = teamSlug ?? firstAdminTeam?.slug ?? '';
6387

@@ -104,34 +128,35 @@ export function ScmProjectDetails({onComplete}: StepProps) {
104128
projectNameResolved.length > 0 &&
105129
teamSlugResolved.length > 0 &&
106130
!!selectedPlatform &&
107-
!createProjectAndRules.isPending;
131+
!isSaving &&
132+
!isLoadingTeams &&
133+
projectsLoaded;
108134

109-
async function handleCreateProject() {
110-
if (!selectedPlatform || !canSubmit) {
135+
function persistFormState() {
136+
if (!selectedPlatform) {
111137
return;
112138
}
139+
setProjectDetailsForm({
140+
projectName: projectNameResolved,
141+
teamSlug: teamSlugResolved,
142+
alertRuleConfig,
143+
platform: selectedPlatform.key,
144+
});
145+
}
113146

114-
trackAnalytics('onboarding.scm_project_details_create_clicked', {organization});
115-
147+
async function createNewProject(platform: OnboardingSelectedSDK) {
148+
setIsSaving(true);
116149
try {
117150
const {project} = await createProjectAndRules.mutateAsync({
118151
projectName: projectNameResolved,
119-
platform: selectedPlatform,
152+
platform,
120153
team: teamSlugResolved,
121154
alertRuleConfig: getRequestDataFragment(alertRuleConfig),
122155
createNotificationAction: () => undefined,
123156
});
124157

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).
128158
setCreatedProjectSlug(project.slug);
129-
setProjectDetailsForm({
130-
projectName: projectNameResolved,
131-
teamSlug: teamSlugResolved,
132-
alertRuleConfig,
133-
platform: selectedPlatform.key,
134-
});
159+
persistFormState();
135160

136161
trackAnalytics('onboarding.scm_project_details_create_succeeded', {
137162
organization,
@@ -143,6 +168,101 @@ export function ScmProjectDetails({onComplete}: StepProps) {
143168
trackAnalytics('onboarding.scm_project_details_create_failed', {organization});
144169
addErrorMessage(t('Failed to create project'));
145170
Sentry.captureException(error);
171+
} finally {
172+
setIsSaving(false);
173+
}
174+
}
175+
176+
async function updateExistingProject(slug: string) {
177+
setIsSaving(true);
178+
try {
179+
const updated = await update(api, {
180+
orgId: organization.slug,
181+
projectId: slug,
182+
data: {name: projectNameResolved},
183+
});
184+
185+
// If the alert config changed, replace the existing rule.
186+
// Onboarding only ever creates a single alert rule.
187+
if (!alertConfigUnchanged) {
188+
const rulesQueryKey: ApiQueryKey = [
189+
getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/', {
190+
path: {
191+
organizationIdOrSlug: organization.slug,
192+
projectIdOrSlug: updated.slug,
193+
},
194+
}),
195+
];
196+
const [[existingRule]] = await queryClient.fetchQuery({
197+
queryKey: rulesQueryKey,
198+
queryFn: fetchDataQuery<IssueAlertRule[]>,
199+
staleTime: 0,
200+
});
201+
if (existingRule) {
202+
await fetchMutation({
203+
method: 'DELETE',
204+
url: `/projects/${organization.slug}/${updated.slug}/rules/${existingRule.id}/`,
205+
});
206+
}
207+
208+
const fragment = getRequestDataFragment(alertRuleConfig);
209+
if (fragment.shouldCreateCustomRule) {
210+
await createProjectRules.mutateAsync({
211+
projectSlug: updated.slug,
212+
name: updated.name,
213+
conditions: fragment.conditions,
214+
actions: fragment.actions,
215+
actionMatch: fragment.actionMatch,
216+
frequency: fragment.frequency,
217+
});
218+
}
219+
}
220+
221+
setCreatedProjectSlug(updated.slug);
222+
persistFormState();
223+
onComplete(undefined, selectedFeatures ? {product: selectedFeatures} : undefined);
224+
} catch (error) {
225+
addErrorMessage(t('Failed to update project'));
226+
Sentry.captureException(error);
227+
} finally {
228+
setIsSaving(false);
229+
}
230+
}
231+
232+
const savedAlert = projectDetailsForm?.alertRuleConfig;
233+
const alertConfigUnchanged =
234+
alertRuleConfig.alertSetting === savedAlert?.alertSetting &&
235+
alertRuleConfig.interval === savedAlert?.interval &&
236+
alertRuleConfig.metric === savedAlert?.metric &&
237+
alertRuleConfig.threshold === savedAlert?.threshold;
238+
239+
function handleCreateProject() {
240+
if (!selectedPlatform || !canSubmit) {
241+
return;
242+
}
243+
244+
trackAnalytics('onboarding.scm_project_details_create_clicked', {organization});
245+
246+
if (projectExists && createdProjectSlug) {
247+
const platformChanged = projectDetailsForm?.platform !== selectedPlatform.key;
248+
249+
if (platformChanged) {
250+
// Platform changed — abandon the old project and create a new one,
251+
// matching legacy onboarding behavior.
252+
createNewProject(selectedPlatform);
253+
} else if (
254+
projectNameResolved === projectDetailsForm?.projectName &&
255+
teamSlugResolved === projectDetailsForm?.teamSlug &&
256+
alertConfigUnchanged
257+
) {
258+
// Nothing changed — reuse the existing project as-is.
259+
onComplete(undefined, selectedFeatures ? {product: selectedFeatures} : undefined);
260+
} else {
261+
// Same platform but settings changed — update in place.
262+
updateExistingProject(createdProjectSlug);
263+
}
264+
} else {
265+
createNewProject(selectedPlatform);
146266
}
147267
}
148268

@@ -218,7 +338,7 @@ export function ScmProjectDetails({onComplete}: StepProps) {
218338
priority="primary"
219339
onClick={handleCreateProject}
220340
disabled={!canSubmit}
221-
busy={createProjectAndRules.isPending}
341+
busy={isSaving}
222342
icon={<IconProject />}
223343
>
224344
{t('Create project')}

0 commit comments

Comments
 (0)