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,