diff --git a/static/app/components/onboarding/onboardingContext.tsx b/static/app/components/onboarding/onboardingContext.tsx index 1db8257436d939..c365440c549455 100644 --- a/static/app/components/onboarding/onboardingContext.tsx +++ b/static/app/components/onboarding/onboardingContext.tsx @@ -4,15 +4,30 @@ import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedD import type {Integration, Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; +import type {AlertRuleOptions} from 'sentry/views/projectInstall/issueAlertOptions'; + +/** + * Persisted form state from the SCM project details step. Stored so the + * form can be restored when the user navigates back from setup-docs. + * Cleared by the platform features step when the platform changes, so + * stale inputs don't carry across platform selections. + */ +export interface ProjectDetailsFormState { + alertRuleConfig?: AlertRuleOptions; + projectName?: string; + teamSlug?: string; +} type OnboardingContextProps = { clearDerivedState: () => void; setCreatedProjectSlug: (slug?: string) => void; + setProjectDetailsForm: (form?: ProjectDetailsFormState) => void; setSelectedFeatures: (features?: ProductSolution[]) => void; setSelectedIntegration: (integration?: Integration) => void; setSelectedPlatform: (selectedSDK?: OnboardingSelectedSDK) => void; setSelectedRepository: (repo?: Repository) => void; createdProjectSlug?: string; + projectDetailsForm?: ProjectDetailsFormState; selectedFeatures?: ProductSolution[]; selectedIntegration?: Integration; selectedPlatform?: OnboardingSelectedSDK; @@ -21,6 +36,7 @@ type OnboardingContextProps = { export type OnboardingSessionState = { createdProjectSlug?: string; + projectDetailsForm?: ProjectDetailsFormState; selectedFeatures?: ProductSolution[]; selectedIntegration?: Integration; selectedPlatform?: OnboardingSelectedSDK; @@ -41,6 +57,8 @@ const OnboardingContext = createContext({ setSelectedFeatures: () => {}, createdProjectSlug: undefined, setCreatedProjectSlug: () => {}, + projectDetailsForm: undefined, + setProjectDetailsForm: () => {}, clearDerivedState: () => {}, }); @@ -84,6 +102,10 @@ export function OnboardingContextProvider({children, initialValue}: ProviderProp setCreatedProjectSlug: (createdProjectSlug?: string) => { setOnboarding(prev => ({...prev, createdProjectSlug})); }, + projectDetailsForm: onboarding?.projectDetailsForm, + setProjectDetailsForm: (projectDetailsForm?: ProjectDetailsFormState) => { + setOnboarding(prev => ({...prev, projectDetailsForm})); + }, // Clear state derived from the selected repository (platform, features, // created project) without wiping the entire session. Use this when the // repo changes so downstream steps start fresh. @@ -93,6 +115,7 @@ export function OnboardingContextProvider({children, initialValue}: ProviderProp selectedPlatform: undefined, selectedFeatures: undefined, createdProjectSlug: undefined, + projectDetailsForm: undefined, })); }, }), diff --git a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx index 2d11e246cdc512..33ff65d02985da 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.spec.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.spec.tsx @@ -420,6 +420,48 @@ describe('ScmPlatformFeatures', () => { expect(screen.getByRole('checkbox', {name: /Profiling/})).not.toBeChecked(); }); + it('clears persisted project details form when detected platform changes', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/repos/42/platforms/`, + body: { + platforms: [ + DetectedPlatformFixture(), + DetectedPlatformFixture({ + platform: 'python-django', + language: 'Python', + priority: 2, + }), + ], + }, + }); + + render( + null} + />, + { + organization, + additionalWrapper: makeOnboardingWrapper({ + selectedRepository: mockRepository, + projectDetailsForm: { + projectName: 'stale-name', + teamSlug: 'stale-team', + }, + }), + } + ); + + const djangoCard = await screen.findByRole('radio', {name: /Django/}); + await userEvent.click(djangoCard); + + await waitFor(() => { + const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); + expect(stored.projectDetailsForm).toBeUndefined(); + }); + }); + describe('analytics', () => { let trackAnalyticsSpy: jest.SpyInstance; diff --git a/static/app/views/onboarding/scmPlatformFeatures.tsx b/static/app/views/onboarding/scmPlatformFeatures.tsx index fadfe5afce31ca..bd50f67bffeb63 100644 --- a/static/app/views/onboarding/scmPlatformFeatures.tsx +++ b/static/app/views/onboarding/scmPlatformFeatures.tsx @@ -84,6 +84,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { setSelectedPlatform, selectedFeatures, setSelectedFeatures, + setProjectDetailsForm, } = useOnboardingContext(); const [showManualPicker, setShowManualPicker] = useState(false); @@ -200,6 +201,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { const applyPlatformSelection = (sdk: OnboardingSelectedSDK) => { setSelectedPlatform(sdk); setSelectedFeatures([ProductSolution.ERROR_MONITORING]); + setProjectDetailsForm(undefined); }; const handleManualPlatformSelect = async (option: {value: string}) => { @@ -267,6 +269,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { setPlatform(platformKey); setSelectedFeatures([ProductSolution.ERROR_MONITORING]); + setProjectDetailsForm(undefined); trackAnalytics('onboarding.scm_platform_selected', { organization, @@ -281,6 +284,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { } setPlatform(platformKey); setSelectedFeatures([ProductSolution.ERROR_MONITORING]); + setProjectDetailsForm(undefined); trackAnalytics('onboarding.scm_platform_selected', { organization, @@ -303,6 +307,7 @@ export function ScmPlatformFeatures({onComplete}: StepProps) { if (detectedPlatformKey) { setPlatform(detectedPlatformKey); setSelectedFeatures([ProductSolution.ERROR_MONITORING]); + setProjectDetailsForm(undefined); } } diff --git a/static/app/views/onboarding/scmProjectDetails.spec.tsx b/static/app/views/onboarding/scmProjectDetails.spec.tsx index c0a38f35ea065a..ba0c4691837dca 100644 --- a/static/app/views/onboarding/scmProjectDetails.spec.tsx +++ b/static/app/views/onboarding/scmProjectDetails.spec.tsx @@ -299,6 +299,80 @@ describe('ScmProjectDetails', () => { expect(stored.selectedPlatform?.key).toBe('javascript-nextjs'); }); + it('restores form inputs from persisted projectDetailsForm', async () => { + render( + null} + />, + { + organization, + additionalWrapper: makeOnboardingWrapper({ + selectedPlatform: mockPlatform, + projectDetailsForm: { + projectName: 'my-saved-name', + teamSlug: teamWithAccess.slug, + }, + }), + } + ); + + const input = await screen.findByPlaceholderText('project-name'); + expect(input).toHaveValue('my-saved-name'); + }); + + it('persists form state to context on successful creation', async () => { + MockApiClient.addMockResponse({ + url: `/teams/${organization.slug}/${teamWithAccess.slug}/projects/`, + method: 'POST', + body: ProjectFixture({slug: 'javascript-nextjs', name: 'javascript-nextjs'}), + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + body: organization, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/teams/`, + body: [teamWithAccess], + }); + + const onComplete = jest.fn(); + + render( + null} + />, + { + organization, + additionalWrapper: makeOnboardingWrapper({ + selectedPlatform: mockPlatform, + }), + } + ); + + await userEvent.click(await screen.findByRole('button', {name: 'Create project'})); + + await waitFor(() => { + expect(onComplete).toHaveBeenCalled(); + }); + + const stored = JSON.parse(sessionStorageWrapper.getItem('onboarding') ?? '{}'); + expect(stored.projectDetailsForm).toEqual( + expect.objectContaining({ + projectName: 'javascript-nextjs', + teamSlug: teamWithAccess.slug, + }) + ); + expect(stored.projectDetailsForm.alertRuleConfig).toBeDefined(); + }); + it('shows error message on project creation failure', async () => { const onComplete = jest.fn(); diff --git a/static/app/views/onboarding/scmProjectDetails.tsx b/static/app/views/onboarding/scmProjectDetails.tsx index 65741fb574b488..d90f36c55b4bbc 100644 --- a/static/app/views/onboarding/scmProjectDetails.tsx +++ b/static/app/views/onboarding/scmProjectDetails.tsx @@ -33,8 +33,13 @@ const PROJECT_DETAILS_WIDTH = '285px'; export function ScmProjectDetails({onComplete}: StepProps) { const organization = useOrganization(); - const {selectedPlatform, selectedFeatures, setCreatedProjectSlug} = - useOnboardingContext(); + const { + selectedPlatform, + selectedFeatures, + setCreatedProjectSlug, + projectDetailsForm, + setProjectDetailsForm, + } = useOnboardingContext(); const {teams} = useTeams(); const createProjectAndRules = useCreateProjectAndRules(); useEffect(() => { @@ -44,15 +49,20 @@ export function ScmProjectDetails({onComplete}: StepProps) { const firstAdminTeam = teams.find((team: Team) => team.access.includes('team:admin')); const defaultName = slugify(selectedPlatform?.key ?? ''); - // State tracks user edits; derived values fall back to defaults from context/teams - const [projectName, setProjectName] = useState(null); - const [teamSlug, setTeamSlug] = useState(null); + // State tracks user edits. When the user navigates back from setup-docs + // the persisted projectDetailsForm restores their previous inputs. + const [projectName, setProjectName] = useState( + projectDetailsForm?.projectName ?? null + ); + const [teamSlug, setTeamSlug] = useState( + projectDetailsForm?.teamSlug ?? null + ); const projectNameResolved = projectName ?? defaultName; const teamSlugResolved = teamSlug ?? firstAdminTeam?.slug ?? ''; const [alertRuleConfig, setAlertRuleConfig] = useState( - DEFAULT_ISSUE_ALERT_OPTIONS_VALUES + projectDetailsForm?.alertRuleConfig ?? DEFAULT_ISSUE_ALERT_OPTIONS_VALUES ); function handleAlertChange( @@ -116,6 +126,11 @@ export function ScmProjectDetails({onComplete}: StepProps) { // the project via useRecentCreatedProject without corrupting // selectedPlatform.key (which the platform features step needs). setCreatedProjectSlug(project.slug); + setProjectDetailsForm({ + projectName: projectNameResolved, + teamSlug: teamSlugResolved, + alertRuleConfig, + }); trackAnalytics('onboarding.scm_project_details_create_succeeded', { organization,