Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions static/app/views/onboarding/scmProjectDetails.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
OnboardingContextProvider,
type OnboardingSessionState,
} from 'sentry/components/onboarding/onboardingContext';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import {TeamStore} from 'sentry/stores/teamStore';
import type {OnboardingSelectedSDK} from 'sentry/types/onboarding';
import * as analytics from 'sentry/utils/analytics';
Expand Down Expand Up @@ -44,6 +45,7 @@ describe('ScmProjectDetails', () => {
beforeEach(() => {
sessionStorageWrapper.clear();
TeamStore.loadInitialData([teamWithAccess]);
ProjectsStore.loadInitialData([]);

// useCreateNotificationAction queries messaging integrations on mount
MockApiClient.addMockResponse({
Expand Down
156 changes: 139 additions & 17 deletions static/app/views/onboarding/scmProjectDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,28 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';

import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {update} from 'sentry/actionCreators/projects';
import {useOnboardingContext} from 'sentry/components/onboarding/onboardingContext';
import {useCreateProjectAndRules} from 'sentry/components/onboarding/useCreateProjectAndRules';
import {useCreateProjectRules} from 'sentry/components/onboarding/useCreateProjectRules';
import {TeamSelector} from 'sentry/components/teamSelector';
import {IconGroup, IconProject, IconSiren} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {IssueAlertRule} from 'sentry/types/alerts';
import type {OnboardingSelectedSDK} from 'sentry/types/onboarding';
import type {Team} from 'sentry/types/organization';
import {trackAnalytics} from 'sentry/utils/analytics';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {
type ApiQueryKey,
fetchDataQuery,
fetchMutation,
useQueryClient,
} from 'sentry/utils/queryClient';
import {slugify} from 'sentry/utils/slugify';
import {useApi} from 'sentry/utils/useApi';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useProjects} from 'sentry/utils/useProjects';
import {useTeams} from 'sentry/utils/useTeams';
import {
DEFAULT_ISSUE_ALERT_OPTIONS_VALUES,
Expand All @@ -32,16 +45,21 @@ import type {StepProps} from './types';
const PROJECT_DETAILS_WIDTH = '285px';

export function ScmProjectDetails({onComplete}: StepProps) {
const api = useApi();
const queryClient = useQueryClient();
const organization = useOrganization();
const {
selectedPlatform,
selectedFeatures,
createdProjectSlug,
setCreatedProjectSlug,
projectDetailsForm,
setProjectDetailsForm,
} = useOnboardingContext();
const {teams} = useTeams();
const {teams, fetching: isLoadingTeams} = useTeams();
const {projects, initiallyLoaded: projectsLoaded} = useProjects();
const createProjectAndRules = useCreateProjectAndRules();
const createProjectRules = useCreateProjectRules();
useEffect(() => {
trackAnalytics('onboarding.scm_project_details_step_viewed', {organization});
}, [organization]);
Expand All @@ -58,6 +76,12 @@ export function ScmProjectDetails({onComplete}: StepProps) {
projectDetailsForm?.teamSlug ?? null
);

const [isSaving, setIsSaving] = useState(false);

// Safety-net: verify the previously created project still exists in the store.
const projectExists =
!!createdProjectSlug && projects.some(p => p.slug === createdProjectSlug);

const projectNameResolved = projectName ?? defaultName;
const teamSlugResolved = teamSlug ?? firstAdminTeam?.slug ?? '';

Expand Down Expand Up @@ -104,34 +128,35 @@ export function ScmProjectDetails({onComplete}: StepProps) {
projectNameResolved.length > 0 &&
teamSlugResolved.length > 0 &&
!!selectedPlatform &&
!createProjectAndRules.isPending;
!isSaving &&
!isLoadingTeams &&
projectsLoaded;

async function handleCreateProject() {
if (!selectedPlatform || !canSubmit) {
function persistFormState() {
if (!selectedPlatform) {
return;
}
setProjectDetailsForm({
projectName: projectNameResolved,
teamSlug: teamSlugResolved,
alertRuleConfig,
platform: selectedPlatform.key,
});
}

trackAnalytics('onboarding.scm_project_details_create_clicked', {organization});

async function createNewProject(platform: OnboardingSelectedSDK) {
setIsSaving(true);
try {
const {project} = await createProjectAndRules.mutateAsync({
projectName: projectNameResolved,
platform: selectedPlatform,
platform,
team: teamSlugResolved,
alertRuleConfig: getRequestDataFragment(alertRuleConfig),
createNotificationAction: () => undefined,
});

// Store the project slug separately so onboarding.tsx can find
// the project via useRecentCreatedProject without corrupting
// selectedPlatform.key (which the platform features step needs).
setCreatedProjectSlug(project.slug);
setProjectDetailsForm({
projectName: projectNameResolved,
teamSlug: teamSlugResolved,
alertRuleConfig,
platform: selectedPlatform.key,
});
persistFormState();

trackAnalytics('onboarding.scm_project_details_create_succeeded', {
organization,
Expand All @@ -143,6 +168,103 @@ export function ScmProjectDetails({onComplete}: StepProps) {
trackAnalytics('onboarding.scm_project_details_create_failed', {organization});
addErrorMessage(t('Failed to create project'));
Sentry.captureException(error);
} finally {
setIsSaving(false);
}
}

async function updateExistingProject(slug: string) {
setIsSaving(true);
try {
const updated = await update(api, {
orgId: organization.slug,
projectId: slug,
data: {name: projectNameResolved},
});

// If the alert config changed, replace the existing rule.
// Onboarding only ever creates a single alert rule.
if (!alertConfigUnchanged) {
const rulesQueryKey: ApiQueryKey = [
getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/', {
path: {
organizationIdOrSlug: organization.slug,
projectIdOrSlug: updated.slug,
},
}),
];
const [[existingRule]] = await queryClient.fetchQuery({
queryKey: rulesQueryKey,
queryFn: fetchDataQuery<IssueAlertRule[]>,
staleTime: 0,
});
if (existingRule) {
await fetchMutation({
method: 'DELETE',
url: `/projects/${organization.slug}/${updated.slug}/rules/${existingRule.id}/`,
});
}

const fragment = getRequestDataFragment(alertRuleConfig);
if (fragment.shouldCreateCustomRule) {
await createProjectRules.mutateAsync({
projectSlug: updated.slug,
name: updated.name,
conditions: fragment.conditions,
actions: fragment.actions,
actionMatch: fragment.actionMatch,
frequency: fragment.frequency,
});
}
}

setCreatedProjectSlug(updated.slug);
persistFormState();
onComplete(undefined, selectedFeatures ? {product: selectedFeatures} : undefined);
} catch (error) {
addErrorMessage(t('Failed to update project'));
Sentry.captureException(error);
} finally {
setIsSaving(false);
}
}

const savedAlert = projectDetailsForm?.alertRuleConfig;
const alertConfigUnchanged =
alertRuleConfig.alertSetting === savedAlert?.alertSetting &&
alertRuleConfig.interval === savedAlert?.interval &&
alertRuleConfig.metric === savedAlert?.metric &&
alertRuleConfig.threshold === savedAlert?.threshold;

function handleCreateProject() {
if (!selectedPlatform || !canSubmit) {
return;
}

trackAnalytics('onboarding.scm_project_details_create_clicked', {organization});

if (projectExists && createdProjectSlug) {
const platformChanged = projectDetailsForm?.platform !== selectedPlatform.key;
const nameChanged = projectNameResolved !== projectDetailsForm?.projectName;

if (platformChanged || nameChanged) {
// Platform or name changed — abandon the old project and create a
// new one. The slug is derived from the name at creation time and
// is immutable, so a name change needs a fresh project to get a
// matching slug.
createNewProject(selectedPlatform);
} else if (
teamSlugResolved === projectDetailsForm?.teamSlug &&
alertConfigUnchanged
) {
// Nothing changed — reuse the existing project as-is.
onComplete(undefined, selectedFeatures ? {product: selectedFeatures} : undefined);
} else {
// Team or alert config changed — update in place.
updateExistingProject(createdProjectSlug);
}
} else {
createNewProject(selectedPlatform);
}
}

Expand Down Expand Up @@ -218,7 +340,7 @@ export function ScmProjectDetails({onComplete}: StepProps) {
priority="primary"
onClick={handleCreateProject}
disabled={!canSubmit}
busy={createProjectAndRules.isPending}
busy={isSaving}
icon={<IconProject />}
>
{t('Create project')}
Expand Down
Loading