From 63dffeb2b2c6c09952d895a3f8ec35e8bb8cbce6 Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Wed, 15 Apr 2026 17:30:55 -0500 Subject: [PATCH] ref(onboarding): Persist project details form state in onboarding context Add projectDetailsForm to the onboarding session state so the SCM project details step can restore the user's previous inputs (name, team, alert config) when navigating back from setup-docs. This mirrors the pattern used by createProject.tsx which persists form state to localStorage for the same purpose. --- .../onboarding/onboardingContext.tsx | 25 ++++++ .../onboarding/scmProjectDetails.spec.tsx | 76 +++++++++++++++++++ .../views/onboarding/scmProjectDetails.tsx | 28 +++++-- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/static/app/components/onboarding/onboardingContext.tsx b/static/app/components/onboarding/onboardingContext.tsx index 1db8257436d939..923fd39310b797 100644 --- a/static/app/components/onboarding/onboardingContext.tsx +++ b/static/app/components/onboarding/onboardingContext.tsx @@ -3,16 +3,33 @@ import {createContext, useContext, useMemo} from 'react'; import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; import type {Integration, Repository} from 'sentry/types/integrations'; import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; +import type {PlatformKey} from 'sentry/types/project'; 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. + * `platform` records the platform at creation time so we can detect + * platform changes when the user navigates back through earlier steps. + */ +export interface ProjectDetailsFormState { + alertRuleConfig?: AlertRuleOptions; + platform?: PlatformKey; + 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 +38,7 @@ type OnboardingContextProps = { export type OnboardingSessionState = { createdProjectSlug?: string; + projectDetailsForm?: ProjectDetailsFormState; selectedFeatures?: ProductSolution[]; selectedIntegration?: Integration; selectedPlatform?: OnboardingSelectedSDK; @@ -41,6 +59,8 @@ const OnboardingContext = createContext({ setSelectedFeatures: () => {}, createdProjectSlug: undefined, setCreatedProjectSlug: () => {}, + projectDetailsForm: undefined, + setProjectDetailsForm: () => {}, clearDerivedState: () => {}, }); @@ -84,6 +104,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 +117,7 @@ export function OnboardingContextProvider({children, initialValue}: ProviderProp selectedPlatform: undefined, selectedFeatures: undefined, createdProjectSlug: undefined, + projectDetailsForm: undefined, })); }, }), diff --git a/static/app/views/onboarding/scmProjectDetails.spec.tsx b/static/app/views/onboarding/scmProjectDetails.spec.tsx index c0a38f35ea065a..4e848d553a6f95 100644 --- a/static/app/views/onboarding/scmProjectDetails.spec.tsx +++ b/static/app/views/onboarding/scmProjectDetails.spec.tsx @@ -299,6 +299,82 @@ 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, + platform: 'javascript-nextjs', + }, + }), + } + ); + + 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, + platform: 'javascript-nextjs', + }) + ); + 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..558087aba342e3 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,12 @@ 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, + platform: selectedPlatform.key, + }); trackAnalytics('onboarding.scm_project_details_create_succeeded', { organization,